Compare commits

..

No commits in common. 'master' and 'repl' have entirely different histories.
master ... repl

  1. 12
      .luarc.json
  2. 8
      .stylua.toml
  3. 411
      Cargo.lock
  4. 35
      Cargo.toml
  5. 9
      Makefile
  6. 39
      README.md
  7. 16411
      assets/public_suffix_list.dat
  8. 18
      docs/README.md
  9. 6
      docs/concurrency.md
  10. 187
      docs/fs.md
  11. 103
      docs/http.md
  12. 16
      docs/log.md
  13. 2
      docs/math.md
  14. 44
      docs/os.md
  15. 110
      docs/require.md
  16. 5
      docs/sqlite.md
  17. 2
      docs/table.md
  18. 546
      docs/tui.md
  19. 2
      docs/utils.md
  20. 44
      lua/coroutines-demo.lua
  21. 36
      lua/demo/args.lua
  22. 124
      lua/demo/mandelbrot.lua
  23. 61
      lua/demo/test.lua
  24. 107
      lua/demo/tui_prompts.lua
  25. 91
      lua/demo/tui_styles.lua
  26. 75
      lua/demo/tui_term.lua
  27. 256
      lua/demo/weather.lua
  28. 53
      lua/stdlib/http.lua
  29. 99
      lua/stdlib/math.lua
  30. 15
      lua/stdlib/sqlite.lua
  31. 340
      lua/stdlib/table.lua
  32. 122
      lua/stdlib/utils.lua
  33. 29
      lua/stubs/README.md
  34. 64
      lua/stubs/fs.lua
  35. 140
      lua/stubs/http.lua
  36. 31
      lua/stubs/log.lua
  37. 31
      lua/stubs/os.lua
  38. 16
      lua/stubs/require.lua
  39. 72
      lua/stubs/sqlite.lua
  40. 14
      lua/stubs/task.lua
  41. 318
      lua/stubs/tui.lua
  42. 40
      lua/stubs/utils.lua
  43. 53
      lua/test.lua
  44. 36
      lua/test_sqlite.lua
  45. 313
      src/lua_tests/async_tests.rs
  46. 226
      src/lua_tests/core_tests.rs
  47. 436
      src/lua_tests/fs_tests.rs
  48. 647
      src/lua_tests/http_tests.rs
  49. 398
      src/lua_tests/math_tests.rs
  50. 45
      src/lua_tests/mod.rs
  51. 212
      src/lua_tests/os_tests.rs
  52. 262
      src/lua_tests/require_tests.rs
  53. 1497
      src/lua_tests/table_tests.rs
  54. 509
      src/lua_tests/tui_tests.rs
  55. 813
      src/lua_tests/utils_tests.rs
  56. 189
      src/main.rs
  57. 25
      src/repl.rs
  58. 258
      src/stdlib/fs/mod.rs
  59. 736
      src/stdlib/http.rs
  60. 37
      src/stdlib/logging.rs
  61. 116
      src/stdlib/lua_dump.rs
  62. 20
      src/stdlib/mod.rs
  63. 54
      src/stdlib/os_ext.rs
  64. 92
      src/stdlib/perm/mod.rs
  65. 775
      src/stdlib/perm/policy.rs
  66. 110
      src/stdlib/perm/prompt.rs
  67. 432
      src/stdlib/perm/store.rs
  68. 222
      src/stdlib/require.rs
  69. 194
      src/stdlib/sqlite.rs
  70. 23
      src/stdlib/task.rs
  71. 291
      src/stdlib/tui/blocks.rs
  72. 886
      src/stdlib/tui/markup.rs
  73. 57
      src/stdlib/tui/mod.rs
  74. 328
      src/stdlib/tui/opts.rs
  75. 370
      src/stdlib/tui/output.rs
  76. 742
      src/stdlib/tui/prompts.rs
  77. 586
      src/stdlib/tui/term.rs
  78. 49
      src/stdlib/utils.rs
  79. 2
      tmp/.gitignore

@ -1,12 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json",
"runtime.version": "Lua 5.5",
"runtime.builtin": {
"io": "disable",
"debug": "disable",
"package": "disable"
},
"runtime.path": ["lua/?.lua", "lua/?/init.lua"],
"workspace.checkThirdParty": false,
"workspace.ignoreDir": ["target", "tmp"]
}

@ -1,8 +0,0 @@
column_width = 120
line_endings = "Unix"
indent_type = "Spaces"
indent_width = 4
quote_style = "AutoPreferDouble"
call_parentheses = "Input"
# never allow `if x then y end` on one line — always expand after then/do
collapse_simple_statement = "Never"

411
Cargo.lock generated

@ -2,6 +2,27 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "a"
version = "0.1.0"
dependencies = [
"chrono",
"clap",
"digest_auth",
"env_logger",
"futures",
"log",
"mlua",
"reqwest",
"rusqlite",
"rustls",
"rustyline",
"serde",
"serde_json",
"thiserror",
"tokio",
]
[[package]]
name = "ahash"
version = "0.8.12"
@ -88,12 +109,6 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "ascii"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
[[package]]
name = "atomic-waker"
version = "1.1.2"
@ -190,12 +205,6 @@ dependencies = [
"windows-link",
]
[[package]]
name = "chunked_transfer"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
[[package]]
name = "clap"
version = "4.6.1"
@ -251,15 +260,6 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "colored"
version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "combine"
version = "4.6.7"
@ -270,44 +270,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "convert_case"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "cookie"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]]
name = "cookie_store"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
dependencies = [
"cookie",
"document-features",
"idna",
"log",
"publicsuffix",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@ -343,33 +305,6 @@ dependencies = [
"libc",
]
[[package]]
name = "crossterm"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
dependencies = [
"bitflags 2.13.0",
"crossterm_winapi",
"derive_more",
"document-features",
"mio",
"parking_lot",
"rustix",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
"winapi",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
@ -412,34 +347,6 @@ dependencies = [
"thiserror",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "derive_more"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"rustc_version",
"syn",
]
[[package]]
name = "digest"
version = "0.10.7"
@ -474,27 +381,6 @@ dependencies = [
"syn",
]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]]
name = "dotenvy"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "either"
version = "1.16.0"
@ -584,12 +470,6 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fd-lock"
version = "4.0.4"
@ -710,15 +590,6 @@ dependencies = [
"slab",
]
[[package]]
name = "fuzzy-matcher"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94"
dependencies = [
"thread_local",
]
[[package]]
name = "generic-array"
version = "0.14.7"
@ -740,17 +611,6 @@ dependencies = [
"wasi",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "h2"
version = "0.4.14"
@ -854,12 +714,6 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.10.1"
@ -1058,22 +912,6 @@ dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "inquire"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756"
dependencies = [
"bitflags 2.13.0",
"chrono",
"crossterm",
"dyn-clone",
"fuzzy-matcher",
"tempfile",
"unicode-segmentation",
"unicode-width 0.2.2",
]
[[package]]
name = "ipnet"
version = "2.12.0"
@ -1206,12 +1044,6 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "lock_api"
version = "0.4.14"
@ -1246,39 +1078,6 @@ dependencies = [
"which",
]
[[package]]
name = "luna"
version = "0.1.0"
dependencies = [
"base64",
"chrono",
"clap",
"colored",
"cookie_store",
"crossterm",
"digest_auth",
"dotenvy",
"env_logger",
"form_urlencoded",
"futures",
"inquire",
"log",
"mlua",
"publicsuffix",
"reqwest",
"reqwest_cookie_store",
"rusqlite",
"rustix",
"rustls",
"rustyline",
"serde",
"serde_json",
"tempfile",
"thiserror",
"tiny_http",
"tokio",
]
[[package]]
name = "md-5"
version = "0.10.6"
@ -1308,7 +1107,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
@ -1369,12 +1167,6 @@ dependencies = [
"libc",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-traits"
version = "0.2.19"
@ -1476,12 +1268,6 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@ -1522,22 +1308,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "psl-types"
version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
[[package]]
name = "publicsuffix"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
dependencies = [
"idna",
"psl-types",
]
[[package]]
name = "quote"
version = "1.0.45"
@ -1547,12 +1317,6 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "radix_trie"
version = "0.2.1"
@ -1590,7 +1354,7 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
"getrandom",
]
[[package]]
@ -1639,8 +1403,6 @@ checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
dependencies = [
"base64",
"bytes",
"cookie",
"cookie_store",
"encoding_rs",
"futures-core",
"h2",
@ -1670,18 +1432,6 @@ dependencies = [
"web-sys",
]
[[package]]
name = "reqwest_cookie_store"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "beb98c4d52ae6ceed18a0dff0564717623dd75fae08619ae0927332f0a81abbc"
dependencies = [
"bytes",
"cookie_store",
"reqwest",
"url",
]
[[package]]
name = "ring"
version = "0.17.14"
@ -1690,7 +1440,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"getrandom",
"libc",
"untrusted",
"windows-sys 0.52.0",
@ -1834,7 +1584,7 @@ dependencies = [
"nix",
"radix_trie",
"unicode-segmentation",
"unicode-width 0.1.14",
"unicode-width",
"utf8parse",
"windows-sys 0.52.0",
]
@ -1962,27 +1712,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio",
"signal-hook",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
@ -2101,19 +1830,6 @@ dependencies = [
"libc",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "thiserror"
version = "2.0.18"
@ -2134,57 +1850,6 @@ dependencies = [
"syn",
]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50"
dependencies = [
"deranged",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tiny_http"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
dependencies = [
"ascii",
"chunked_transfer",
"httpdate",
"log",
]
[[package]]
name = "tinystr"
version = "0.8.3"
@ -2346,12 +2011,6 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "untrusted"
version = "0.9.0"
@ -2502,22 +2161,6 @@ dependencies = [
"libc",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
@ -2527,12 +2170,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-core"
version = "0.62.2"

@ -1,58 +1,27 @@
[package]
name = "luna"
name = "a"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
# OSC 52 clipboard payloads (tui.setClipboard); already in the tree as a
# transitive dependency.
base64 = "0.22"
chrono = "0.4.45"
# Color type + NO_COLOR/CLICOLOR/tty policy for tui.styled; the ANSI SGR
# sequences themselves are emitted by our own renderer (see stdlib/tui/markup.rs).
colored = "3"
env_logger = "0.11"
log = "0.4"
clap = { version = "4.6.1", features = ["derive"] }
dotenvy = "0.15"
futures = "0.3"
# Terminal size + raw-mode backend; already in the tree as inquire's backend
# (keep the version in lockstep with inquire's own crossterm dependency).
crossterm = "0.29"
# Interactive prompts for the tui module. `date` enables DateSelect (chrono),
# `editor` the $EDITOR-based prompt; neither is in the default set.
inquire = { version = "0.9", features = ["date", "editor"] }
mlua = { version = "0.11.6", features = ["lua55", "vendored", "serde", "async", "anyhow"]}
# reqwest's default rustls provider (aws-lc-rs) null-derefs during the TLS
# handshake in this environment, and native-tls would link system OpenSSL.
# Use rustls with the self-contained, vendored `ring` provider (installed as the
# process default in stdlib::http). `rustls-no-provider` stops reqwest from
# pulling aws-lc-rs back in.
reqwest = { version = "0.13.4", default-features = false, features = ["rustls-no-provider", "charset", "http2", "system-proxy", "cookies"] }
reqwest = { version = "0.13.4", default-features = false, features = ["rustls-no-provider", "charset", "http2", "system-proxy"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
rusqlite = { version = "0.32", features = ["bundled"] }
# flock() guarding the fs-permission store (access.json) against concurrently
# running scripts; already in the tree as a transitive dependency.
rustix = { version = "1", features = ["fs"] }
rustyline = "14"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
thiserror = "2.0.18"
tokio = { version = "1.52.3", features = ["full"] }
digest_auth = "0.3"
# RFC 6265 cookie jar for http sessions; `public_suffix` rejects cookies scoped
# to a whole TLD (Domain=com) using the vendored assets/public_suffix_list.dat.
cookie_store = { version = "0.22.1", features = ["public_suffix"] }
# Mutex wrapper making cookie_store usable as reqwest's cookie provider.
reqwest_cookie_store = "0.10.0"
# WHATWG application/x-www-form-urlencoded serializer for opts.form; already in
# the tree as a mandatory dependency of url (via reqwest).
form_urlencoded = "1.2.2"
# Public-suffix-list parser consumed by cookie_store's `public_suffix` feature.
publicsuffix = "2.3.0"
[dev-dependencies]
tempfile = "3"
# Local HTTP server for the http module's end-to-end tests.
tiny_http = "0.12.0"

@ -1,9 +0,0 @@
.PHONY: make update-psl
make:
cargo build --release
# Refresh the vendored Public Suffix List used for cookie domain checks
# (compiled into the binary by stdlib::http).
update-psl:
curl -sfL https://publicsuffix.org/list/public_suffix_list.dat -o assets/public_suffix_list.dat

@ -1,18 +1,18 @@
# Luna
# `a`
Luna embeds a Lua interpreter and adds a standard library and IO access, so a script can reach the outside world — today an HTTP client, SQLite and interactive terminal prompts, with more of the surface below planned — without additional setup.
`a` embeds a Lua interpreter and adds a standard library and IO access, so a script can reach the network, a database, the filesystem, and the terminal without additional setup.
```sh
luna file.lua # run a script
luna # start an interactive REPL
a file.lua # run a script
a # start an interactive REPL
```
Run `luna` with a script path to execute it, or with no arguments to start an
Run `a` with a script path to execute it, or with no arguments to start an
interactive REPL.
## The REPL
Running `luna` with no arguments starts a read-eval-print loop in the same
Running `a` with no arguments starts a read-eval-print loop in the same
sandboxed environment used for scripts, with the full standard library
available. It is convenient for trying out the library or exploring data.
@ -23,7 +23,7 @@ available. It is convenient for trying out the library or exploring data.
- Async calls such as `os.sleep`, `http.get`, and `task.join` suspend and resume
just as they do in a script — each entry runs on the async executor.
- Line editing and history are provided, with history persisted to
`~/.luna_history`. Press Ctrl-D to exit (`os.exit` is not available in the
`~/.a_history`. Press Ctrl-D to exit (`os.exit` is not available in the
sandbox).
Each entry is compiled as its own chunk, so a `local` declared on one line is
@ -32,24 +32,18 @@ across entries.
## What it is
Luna is not a Lua implementation; it embeds [Lua 5.5](https://www.lua.org/) (via [`mlua`](https://github.com/mlua-rs/mlua)) and adds the parts stock Lua omits. Plain Lua ships almost no standard library and no way to reach the outside world without C modules and a build toolchain. Luna provides a standard library written partly in Rust and partly in Lua, together with IO access.
`a` is not a Lua implementation; it embeds [Lua 5.5](https://www.lua.org/) (via [`mlua`](https://github.com/mlua-rs/mlua)) and adds the parts stock Lua omits. Plain Lua ships almost no standard library and no way to reach the outside world without C modules and a build toolchain. `a` provides a standard library written partly in Rust and partly in Lua, together with IO access.
The result is a single binary that runs a `.lua` file. There is no package manager, build step, or project scaffolding.
## The standard library
The standard library is the main purpose of Luna. Available today:
The standard library is the main purpose of `a`. Planned surface:
- **Networking** — HTTP client (WebSocket and MQTT are planned), guarded by a per-script, per-origin permission system (`--allow-net` and `--no-net` override it).
- **Networking** — HTTP client, WebSocket, and MQTT (3.1.1 and 5).
- **SQLite** — access to embedded SQLite databases.
- **Filesystem** — whole-file read/write and directory listing via [`fs`](docs/fs.md), guarded by the same permission system per folder (`--allow-fs` and `--no-fs` override it; interactive grants are recorded in `access.json`, and `--sandbox` denies both files and network).
- **Terminal UI** — interactive prompts (confirm, text, select, password, number, date), styled/colored output, and terminal control (cursor addressing, alternate screen, scroll regions) via [`tui`](docs/tui.md).
- **Core helpers**`math`, `table`, and `utils` extensions, structured `log`, `os` time helpers, and `task.join` concurrency.
Planned:
- **Filesystem ergonomics** — glob/walk on top of the existing `fs` core.
- **TUI widgets & input** — progress bars, tables, and raw key/mouse events on top of the existing terminal control.
- **Filesystem** — read/write/glob/walk without stock Lua's `io` boilerplate.
- **Terminal UI** — interactive line editing and full-screen TUI, comparable to `ncurses`.
The intent is for the common case to be a single call rather than a multi-step setup.
@ -57,17 +51,16 @@ The intent is for the common case to be a single call rather than a multi-step s
- Embeds Lua 5.5 via `mlua`, on an async core ([`tokio`](https://tokio.rs/)) so IO-heavy scripts don't block.
- A single self-contained binary. No LuaRocks, no make, no virtualenv.
- Scripts run in a **sandboxed** environment modeled on [Luau's safe environment](https://luau.org/sandbox): `io`, `package`, and `debug` are not loaded; `os` is trimmed to its time functions; the bytecode/chunk-loading escape hatches (`load`, `loadfile`, `dofile`, `string.dump`) are removed. The library Luna provides is the supported way to reach the outside world.
- A leading `#!` shebang line is skipped (after an optional UTF-8 BOM), so scripts can be made executable directly. Script files need not be valid UTF-8 — Lua source is byte-oriented.
- Scripts can be split into files with a sandboxed [`require`](docs/require.md): modules are looked up next to the main script (symlinks resolved, so an executable script symlinked into `~/.local/bin` finds its files) and in `$LUNA_INCLUDE_PATHS`, which can also be set in a `.env` beside the script.
- Scripts run in a **sandboxed** environment modeled on [Luau's safe environment](https://luau.org/sandbox): `io`, `package`, and `debug` are not loaded; `os` is trimmed to its time functions; the bytecode/chunk-loading escape hatches (`load`, `loadfile`, `dofile`, `string.dump`) are removed. The library `a` provides is the supported way to reach the outside world.
- A leading `#!` shebang line is skipped, so scripts can be made executable directly.
## Building
```sh
cargo build --release
# binary at target/release/luna
# binary at target/release/a
cargo run -- lua/demo/test.lua # run a script during development
cargo run -- lua/test.lua # run a script during development
```
Requires a Rust toolchain (2024 edition). Lua is vendored and built from source, so no system Lua is needed.

File diff suppressed because it is too large Load Diff

@ -1,8 +1,7 @@
# Luna standard library
# `a` standard library
Reference docs for the library Luna adds on top of Lua 5.5. Every module below is
a **global**, present in every script with no setup (`require`-ing one by name
also works and returns the same table).
Reference docs for the library `a` adds on top of Lua 5.5. Every module below is
a **global** — there is no `require`; they are present in every script.
## Modules
@ -11,18 +10,15 @@ also works and returns the same table).
| [`utils`](utils.md) | JSON encode/decode, the `NULL` marker, error-trapping (`try`/`tryn`), value `dump`. |
| [`math`](math.md) | Additions to stock `math`: `round`, `clamp`, `isFinite`, `sign`, `scale`. |
| [`table`](table.md) | Additions to stock `table`: map/filter/reduce, merge, deep-copy, aggregates, lookups, read-only proxy. |
| [`os`](os.md) | Sandbox additions: `microtime` (sub-second clock), async `sleep`, and `args` (script arguments). |
| [`log`](log.md) | Level-tagged diagnostic logging (`trace`…`error`), gated by `LUNA_LOG`. |
| [`os`](os.md) | Sandbox additions: `microtime` (sub-second clock) and async `sleep`. |
| [`log`](log.md) | Level-tagged diagnostic logging (`trace`…`error`), gated by `RUST_LOG`. |
| [`sqlite`](sqlite.md) | Embedded SQLite: connect, query, parameters, transactions. |
| [`http`](http.md) | HTTP/HTTPS client: requests, JSON, auth, cookie-jar sessions, per-origin permissions. |
| [`fs`](fs.md) | Whole-file read/write and directory listing, behind a per-script folder permission system. |
| [`tui`](tui.md) | Interactive terminal prompts (confirm/text/select/…), ANSI-styled output, message blocks. |
| [`http`](http.md) | HTTP/HTTPS client: requests, JSON, auth, cookie-jar sessions. |
## Concepts
| Page | What it covers |
|------|----------------|
| [Concurrency](concurrency.md) | How async calls suspend, and running work in parallel with `task.join`. |
| [Modules & `require`](require.md) | Splitting scripts into files: search roots, `LUNA_INCLUDE_PATHS`, `.env`, caching, LSP setup. |
See the top-level [README](../README.md) for what Luna is and how to build it.
See the top-level [README](../README.md) for what `a` is and how to build it.

@ -1,6 +1,6 @@
# Concurrency
Luna runs every script on an async core ([tokio](https://tokio.rs/)). The async
`a` runs every script on an async core ([tokio](https://tokio.rs/)). The async
stdlib calls — `os.sleep`, the `http` client, `sqlite` queries — don't block the
OS thread while they wait: they suspend and let other work run. This page covers
how that interacts with Lua coroutines and how to run several pieces of work at
@ -171,5 +171,5 @@ print(#repos .. " public repos")
```
A runnable version of these patterns ships in
[`lua/demo/coroutines-demo.lua`](../lua/demo/coroutines-demo.lua) — run it with
`luna lua/demo/coroutines-demo.lua`.
[`lua/coroutines-demo.lua`](../lua/coroutines-demo.lua) — run it with
`a lua/coroutines-demo.lua`.

@ -1,187 +0,0 @@
# `fs`
Whole-file filesystem access, guarded by a per-script folder permission
system. The sandbox loads no stock `io`; this module is the supported way for
a script to touch files, and every operation first passes a permission check
(see [Permissions](#permissions) below).
The v1 surface is deliberately small — whole files in and out, directory
listing, two predicates, and an explicit permission probe:
```lua
local text = fs.readAll("/home/me/notes.txt")
fs.writeAll("out/report.md", text .. "\n-- processed")
for _, name in ipairs(fs.list(".")) do print(name) end
if fs.isFile("config.json") then ... end
if fs.access("/var/data", "w") then ... end
```
## Functions
### `fs.readAll(path) -> string`
Reads the whole file into a string. Lua strings are byte strings, so binary
content round-trips exactly. Errors if `path` is not a regular file, the OS
refuses, or the permission system denies **read** on the file's directory.
### `fs.writeAll(path, content)`
Writes the whole file: created if missing, truncated if it exists. The
containing directory must already exist (there is no mkdir-p), and a dangling
symlink as the target is refused. Errors if the file can't be created or the
permission system denies **write** on the containing directory.
### `fs.list(path) -> string[]`
The names (not full paths) of a directory's entries, sorted. `.` and `..` are
not included. Errors if `path` is not a directory or **read** on it is
denied.
### `fs.isDir(path) -> boolean`, `fs.isFile(path) -> boolean`
`true` iff the path exists, has the right type, *and* is readable under the
permission system. These never error: a nonexistent path is `false` without
any permission prompt; an unknown permission prompts like any other read (a
denial makes the result `false`).
### `fs.getWorkDir() -> string`
The process's current working directory, as an absolute path. Relative paths
given to the other fs functions resolve against it, so it is the natural base
for building paths to probe with `fs.access`:
```lua
local cwd = fs.getWorkDir()
if fs.access(cwd, "w") then
fs.writeAll("report.txt", text) -- relative = cwd .. "/report.txt"
end
```
Not permission-gated — it reports process state and touches no file content.
### `fs.getScriptDir() -> string|nil`
The directory the main script really lives in, as an absolute path with
symlinks resolved — an installed script invoked through a `~/.local/bin`
symlink gets the directory of the real file. Unlike the working directory,
this doesn't depend on where the script was invoked from, so it is the right
base for assets shipped alongside the script:
```lua
local tmpl = fs.readAll(fs.getScriptDir() .. "/templates/report.html")
```
Returns `nil` in the REPL, where there is no script. Not permission-gated —
it names a place and reads no file content (accessing files under it prompts
like anywhere else).
### `fs.access(dir, mode) -> boolean`
Runs the permission cycle for a directory explicitly — `mode` is `"r"` or
`"w"`:
1. a recorded grant/denial answers immediately;
2. otherwise the interactive prompt asks;
3. with no terminal to ask on, returns `false` **without** recording a
denial (a persisted "no" is always an explicit user decision).
Use it to ask for permission up front at a natural moment instead of
mid-operation. Errors if `dir` is not an existing directory.
## Permissions
Permissions attach to **directories**, not files, and are recorded per
script:
- The permission unit for `readAll`/`writeAll`/`isFile` is the file's
directory; for `list`/`isDir`/`access` it is the directory itself. Paths
are canonicalized first (symlinks resolved, relative paths against the
CWD), so two spellings of the same place share one grant.
- Grants are **recursive**: an allow or deny on a directory covers
everything below it, and the *deepest* recorded ancestor wins — deny
`~/secrets` and it stays denied even though `~` is allowed.
- Read and write are tracked separately; each is approved, denied, or not
decided yet.
When an operation needs a decision that isn't recorded, Luna asks on the
terminal (stderr, so it shows even with stdout piped) — one keypress, no
Enter:
```
luna: permission request
script: /home/me/bin/deploy.lua
wants: read access to directory /home/me/project
[y] allow once [n] deny once [A] allow always [N] never allow:
```
- **`y` / `n`** apply for the rest of this run only.
- **`A` / `N`** are recorded in `access.json` and answer silently from then
on. (A lowercase `a` deliberately does nothing — a slip of the finger must
not persist anything.)
- **Esc / Ctrl-C** count as `n`.
- No terminal (piped stdin, cron): the operation is denied for this run and
*nothing* is recorded.
While a prompt is waiting, `tui` output from concurrently running coroutines
(e.g. a spinner animating during the request that triggered the question) is
held back so it cannot overwrite the question, and resumes with the answer.
### `access.json`
Recorded answers live in `$XDG_CONFIG_HOME/luna/access.json` (usually
`~/.config/luna/access.json`), keyed by the script's canonical path — moving or
renaming a script starts it with a clean slate. Under each script, grants are
grouped by subsystem: directory grants sit under `"fs"`, network-origin grants
(see [`http`](http.md#permissions)) under `"net"`. The file is plain JSON and
safe to hand-edit; each flag is `true` (approved), `false` (denied), or `null`
(not decided yet):
```json
{
"version": 1,
"scripts": {
"/home/me/bin/deploy.lua": {
"fs": {
"/home/me/project": { "read": true, "write": true },
"/etc": { "read": true, "write": false }
},
"net": {
"https://api.example.com": { "access": true }
}
}
}
}
```
Concurrent Luna processes update it safely (the write cycle holds a file
lock and replaces the file atomically).
The **REPL** prompts the same way, but its answers — including `A`/`N` —
last only for the session; an interactive session has no script identity to
record them under.
### `sqlite` and `http` respect the same system
- `sqlite.connect(path)` asks one combined **read/write** question for the
database's directory (SQLite keeps WAL/journal files next to it).
Memory-class databases — `":memory:"`, `""` and their `file:` URI
spellings — touch no file and are always allowed, even under `--no-fs` /
`--sandbox`.
- A session's cookie-jar files (`http.session(path)`, `session:load`,
`session:save`) are read/write checked like any other file.
- `http` requests run the same cycle per **network origin** instead of per
directory — see [Permissions in `http`](http.md#permissions).
## CLI flags
| Flag | Effect |
|------|--------|
| `--allow-fs` | Skip the filesystem checks: everything granted, nothing asked or recorded. |
| `--no-fs` | Deny all filesystem access (fs module, on-disk sqlite databases, cookie-jar files). |
| `--allow-net` / `--no-net` | The same two switches for network origins (see [`http`](http.md#permissions)). |
| `--sandbox` | `--no-fs` plus `--no-net`; overrides the `--allow-*` flags. |
Denials from a flag read `fs.readAll: filesystem access disabled by --no-fs`,
interactive/recorded ones `fs.readAll: access to /some/dir denied`.

@ -44,56 +44,20 @@ filled in.
local resp = http.request("DELETE", "https://api.example.com/items/42")
```
## Permissions
Every request first passes the per-script [permission system](fs.md#permissions)
— the same one that guards the filesystem. Recorded answers live under the
script's `"net"` section in `access.json`; unknown ones are asked on the
terminal:
```
luna: permission request
script: /home/me/bin/report.lua
wants: access to https://api.example.com
[y] allow once [n] deny once [A] allow always [N] never allow:
```
The permission unit is the URL's **origin** — scheme, host, and port, with
everything after the domain (and optional port) stripped: `https://example.com`,
`http://insecure.example.com:8090`. Credentials embedded in the URL
(`https://user:pass@host/…`) are never part of the origin; a scheme-default
port is omitted, any other port is kept. There is a single operation —
**access**, no read/write distinction — and grants are exact-match: approving
`https://example.com` says nothing about a subdomain, another port, or the
plain-`http` origin.
A denial raises `http: access to <origin> denied`. Redirects within a request
are followed without a second check; sensitive headers are stripped on
cross-origin hops (see [Redirects](#redirects)).
CLI flags override the interactive cycle: `--allow-net` grants every origin
silently, `--no-net` denies everything (`http: networking disabled by
--no-net`), and `--sandbox` implies `--no-net`.
## The response
Every request returns a table describing the response:
| Field | Type | Description |
|--------------|-----------|-------------------------------------------------------------------|
|-----------|-----------|-------------------------------------------------------------------|
| `status` | integer | HTTP status code, e.g. `200`, `404`. |
| `ok` | boolean | `true` when `status` is in the 2xx range. |
| `url` | string | The final URL, after any redirects. |
| `headers` | table | Response headers, keyed by **lowercase** name. |
| `setCookies` | table | Every `Set-Cookie` value on the final response, as an array. |
| `body` | string | The raw response body (Lua strings are byte sequences). |
| `json` | function | `resp.json()` parses `body` as JSON. See [JSON](#json). |
Header names are lowercased so you can look them up without guessing the server's
capitalization. A header that appears more than once has its values joined with
`", "` (the HTTP list convention) — except `set-cookie`, whose values can contain
commas: `headers["set-cookie"]` holds only the first one, and the full list is in
`resp.setCookies`.
capitalization. If a header appears more than once, the first value wins.
```lua
local resp = http.get("https://example.com")
@ -115,7 +79,7 @@ The optional `opts` table accepts these fields, all optional:
| `json` | any | Serialized to JSON; sets `Content-Type: application/json`. |
| `form` | table | URL-encoded; sets `Content-Type: application/x-www-form-urlencoded`. |
| `cookies` | table | Cookies for this request, `{session = "abc"}``Cookie:` header. |
| `timeout` | number | Timeout in seconds for the whole request, redirects included (default `30`). |
| `timeout` | number | Per-request timeout in seconds (default `30`). |
| `auth` | table | Basic or digest credentials. See [Authentication](#authentication). |
### Bodies
@ -153,9 +117,7 @@ local resp = http.get("https://example.com", {
```
Per-request `cookies` are sent as a `Cookie` header. With a [session](#sessions),
they are merged with the jar and take precedence on a name collision. Passing
`cookies` *and* a `Cookie` entry in `headers` is ambiguous and raises an error —
pick one.
they are merged with the jar and take precedence on a name collision.
## JSON
@ -254,28 +216,20 @@ local page = s:get("https://example.com/dashboard")
### Cookie behaviour
The jar implements RFC 6265 the way a browser does (it is the `cookie_store`
crate underneath): `Domain`, `Path`, `Expires`, `Max-Age`, `Secure` and
`HttpOnly` attributes all take effect. In particular:
- A cookie is sent only to hosts the `Domain` attribute covers (the setting
host itself when absent), only on matching paths, and only until it expires —
a server deleting a cookie with `Max-Age=0` really removes it from the jar.
- A `Secure` cookie is never sent over plain `http://`.
- A response cannot set a cookie for an unrelated domain, nor for a public
suffix like `com` or `co.uk` — the jar checks Mozilla's Public Suffix List
(compiled into the binary), same as browsers and curl.
A stored cookie is sent on a request when the request host **equals** the cookie's
domain or is a subdomain of it. The jar reads the `name=value` pair and the
`Domain` attribute from each `Set-Cookie` header; other attributes (`Path`,
`Expires`, `Secure`, `HttpOnly`) are ignored. When no `Domain` is given, the
request host is used.
`Set-Cookie` headers on redirect responses are captured too, so a login that
`302`-redirects to a dashboard still records its cookie — and the redirected
request already carries it.
`Set-Cookie` headers set on redirect responses are captured too, so a login that
`302`-redirects to a dashboard still records its cookie.
### Inspecting and clearing
#### `s:cookies()`
Return the unexpired cookies as a nested table, `{domain = {name = value}}`,
for inspection.
Return the jar as a nested table, `{domain = {name = value}}`, for inspection.
```lua
local jar = s:cookies()
@ -295,23 +249,20 @@ Empty the in-memory jar.
Cookies live in memory for the session's lifetime. Save them to reuse a logged-in
session across script runs.
Jar files hold live credentials, so both directions run the **directory**
side of the [permission system](fs.md#permissions) — write for `s:save`, read
for `s:load` and `http.session(path)` — independently of the per-origin
checks the session's requests make.
#### `s:save(path)`
Write the jar to `path` as JSONL — one JSON object per line, one cookie per
line, in `cookie_store`'s format (the full cookie: name, value, domain, path,
expiry). Session cookies — ones without an `Expires`/`Max-Age`, which is what
most login tokens are — are included.
Write the jar to `path` as JSONL — one JSON object per line, one cookie per line:
```jsonl
{"domain":"example.com","name":"session","value":"abc123"}
{"domain":"api.example.com","name":"token","value":"xyz789"}
```
#### `s:load(path)`
Load a jar file written by `s:save`, **replacing** the current jar contents.
`http.session(path)` is the same as creating a session and calling
`:load(path)`. A file that is not a saved jar raises an error.
Merge cookies from a JSONL file into the current jar (existing cookies are kept,
matching names overwritten). `http.session(path)` is the same as creating a
session and calling `:load(path)`.
```lua
-- First run: log in and persist
@ -327,13 +278,9 @@ local page = s:get("https://example.com/dashboard")
## Redirects
Redirects are followed automatically, up to 10 hops; you receive the final
response (`resp.url` tells you where you ended up). A `303`, and a `301`/`302`
in response to a `POST`, are followed as a bodyless `GET`, matching browser
behaviour. For a session, cookies set along the way are captured at each hop.
When a redirect leaves the original host, the `Authorization` and `Cookie`
headers are dropped so credentials never reach a host the request was not
addressed to. Other custom headers follow the redirect, as in browsers and curl.
response. A `303`, and a `301`/`302` in response to a `POST`, are followed as a
bodyless `GET`, matching browser behaviour. For a session, cookies set along the
way are captured at each hop.
## Errors
@ -358,7 +305,7 @@ end
- **HTTPS needs no setup.** The TLS stack (rustls + `ring`) is compiled in; trust
roots come from the system certificate store.
- **A default `User-Agent` is sent** (`luna/<version>`) because some servers reject
- **A default `User-Agent` is sent** (`a/<version>`) because some servers reject
requests without one. Override it with a `User-Agent` entry in `opts.headers`.
- **Requests don't block the event loop.** Network I/O runs on the async core, so
a slow request does not stall other async work (timers, `os.sleep`, SQLite) in

@ -43,16 +43,16 @@ log.debug("request opts:", utils.dump(opts))
## Controlling what is shown
Log output goes to **standard error**, and which levels are shown is set by the
`LUNA_LOG` environment variable. By default only `WARN` and `ERROR` appear:
`RUST_LOG` environment variable. By default only `WARN` and `ERROR` appear:
```sh
luna script.lua # default: warnings and errors only
LUNA_LOG=info luna script.lua # info and above
LUNA_LOG=debug luna script.lua # debug and above
LUNA_LOG=trace luna script.lua # everything
a script.lua # default: warnings and errors only
RUST_LOG=info a script.lua # info and above
RUST_LOG=debug a script.lua # debug and above
RUST_LOG=trace a script.lua # everything
```
A level shows itself and everything more severe — `LUNA_LOG=info` includes
A level shows itself and everything more severe — `RUST_LOG=info` includes
`info`, `warn`, and `error` but not `debug` or `trace`. Messages below the active
level are discarded cheaply, so leaving `log.debug` calls in place costs almost
nothing when they are not enabled.
@ -71,7 +71,7 @@ turn up or down without editing the script.
```lua
local rows = con:query("SELECT * FROM users")
log.info("fetched", #rows, "rows") -- diagnostic; hidden unless LUNA_LOG=info
log.info("fetched", #rows, "rows") -- diagnostic; hidden unless RUST_LOG=info
for _, r in ipairs(rows) do
print(r.name) -- the actual result
end
@ -80,7 +80,7 @@ end
## Notes
- **Always loaded.** No `require`; `log` is a global in every script.
- **Quiet by default.** With no `LUNA_LOG` set, only `warn`/`error` are emitted,
- **Quiet by default.** With no `RUST_LOG` set, only `warn`/`error` are emitted,
so `info`/`debug`/`trace` are silent until you opt in.
- **Arguments are stringified like `print`.** Multiple arguments are joined with
tabs; tables print as their address, so use `utils.dump` for contents.

@ -1,6 +1,6 @@
# `math`
Luna keeps Lua's standard `math` library intact and adds a handful of functions
`a` keeps Lua's standard `math` library intact and adds a handful of functions
that come up constantly in everyday scripting: rounding, clamping, sign, finiteness,
and linear rescaling. The additions live on the same global `math` table, so
`math.floor` and `math.round` sit side by side.

@ -1,11 +1,10 @@
# `os`
Luna runs scripts in a sandbox, so the standard `os` table is trimmed to its
`a` runs scripts in a sandbox, so the standard `os` table is trimmed to its
safe, time-related functions — `os.time`, `os.clock`, `os.date`,
`os.difftime` — while the parts that touch the system
(`os.execute`, `os.getenv`, `os.remove`, `os.exit`, …) are removed. To that
trimmed table Luna adds a high-resolution clock, an async sleep, and access to
the script's command-line arguments.
`os.difftime`, `os.getenv` — while the parts that touch the system
(`os.execute`, `os.remove`, `os.exit`, …) are removed. To that trimmed table `a`
adds two functions: a high-resolution clock and an async sleep.
```lua
local t0 = os.microtime()
@ -30,37 +29,6 @@ It reads the system wall clock, so it tracks real time (and can jump if the cloc
is adjusted); for interval timing the difference of two readings is what you
want.
## `os.args()`
Return the command-line arguments passed to the script, as an array (a fresh
table on every call, so it is safe to mutate). On the `luna` command line the
script's arguments are everything after the script path — even a token spelled
like a luna flag goes to the script there, so luna's own flags (`--sandbox` &
co.) must come before the path. A `--` between the path and the arguments is
accepted and skipped:
```sh
luna script.lua one two --three
luna --sandbox script.lua -- one two --three
```
```lua
-- script.lua
for i, arg in ipairs(os.args()) do
print(i, arg) --> 1 one / 2 two / 3 --three
end
```
An executable script with a `#!/usr/bin/env luna` shebang line receives its
arguments the same way, with no `--` needed:
```sh
./script.lua one two --three
```
In the REPL `os.args()` returns an empty table. Arguments are passed through
as raw byte strings, without UTF-8 conversion.
## `os.sleep(seconds)`
Pause for `seconds` (a float, so `os.sleep(0.1)` is 100 ms). This is an **async**
@ -96,8 +64,8 @@ explanation — the rule is to run anything that needs to sleep through
## Notes
- **The sandbox keeps the time functions.** `os.time`, `os.clock`, `os.date`,
and `os.difftime` work as in stock Lua; functions that touch the system are
not present.
`os.difftime`, and `os.getenv` work as in stock Lua; system-mutating functions
are not present.
- **`microtime` for durations, `time` for timestamps.** Use `os.microtime` when
you need sub-second precision or are timing an interval; `os.time` when whole
seconds suffice.

@ -1,110 +0,0 @@
# Modules & `require`
Luna provides a sandboxed `require` for splitting a script into multiple files.
It follows stock Lua's conventions — dot-separated module names, one execution
per module, cached results — so editors and the Lua language server understand
the imports without special setup.
```lua
local helper = require "lib.helper" -- lib/helper.lua
local db = require "db" -- db.lua or db/init.lua
local mod, path = require "lib.helper" -- second value: the resolved file path
```
## Module names
A name is one or more dot-separated segments; each segment may contain
`A–Z a–z 0–9 _ -`. Dots map to directory separators, so `require "db.migrations"`
looks for `db/migrations.lua`. Paths (`/`, `..`), absolute names, and empty
segments are rejected — a module can only ever be loaded from inside the
search roots.
## Search roots
Names are resolved against a fixed list of directories, in order:
1. **The main script's directory.** Symlinks to the script are resolved first,
so a script symlinked into `~/.local/bin` still loads modules (and its
`.env`, below) from next to the real file. In the REPL, the current working
directory is used instead.
2. **Each entry of `$LUNA_INCLUDE_PATHS`**, colon-separated like `$PATH`.
In every root, two candidates are tried (the Lua language server's default
templates):
```
<root>/<name with dots as slashes>.lua
<root>/<name with dots as slashes>/init.lua
```
The first hit wins. If a later root (or the `init.lua` form) also matches, a
warning is logged about the shadowed file. When nothing matches, the error
lists every path that was tried, in the stock Lua format.
## `.env`
Before resolving anything, Luna loads a `.env` file from the main script's
real directory (the CWD in the REPL), if one exists. Variables already set in
the real environment take precedence. This is the intended place to set
`LUNA_INCLUDE_PATHS` for a project:
```
# myproject/.env
LUNA_INCLUDE_PATHS=/home/me/lua-libs:/opt/shared-lua
```
Since the script's symlink is resolved first, `myproject/main.lua` symlinked
as `~/.local/bin/mytool` still picks up `myproject/.env`.
## Semantics
- **A module file is a plain chunk.** Its `local`s are private to the file; it
shares globals with the rest of the program; whatever it returns is the
module's value (conventionally a table). Like stock Lua, the chunk receives
the module name and the resolved file path as `...`.
- **Executed once.** The return value is cached by the file's canonical path,
so two names or symlinks reaching the same file share one instance, and
`require` of an already-loaded module is just a table lookup. A module that
returns nothing is cached as `true`.
- **Return values.** `require` returns the module value and, for file modules,
the resolved path as a second value.
- **Built-ins.** `require "http"`, `require "utils"`, etc. return the same
table as the corresponding global, so code written for stock Lua's habits
works unchanged.
- **Cycles.** A require cycle (`a` requires `b` requires `a`) raises a
`circular require` error instead of recursing forever.
- **Errors.** If a module raises while loading, the error propagates to the
requiring code and nothing is cached — a later `require` retries the load.
- Module files may use the async stdlib (`http`, `os.sleep`, …) at top level,
and may start with a shebang and/or UTF-8 BOM, same as the main script.
## Editor / LSP setup
The resolution rules match the
[Lua language server](https://luals.github.io/) defaults, so a minimal
`.luarc.json` next to your project gives go-to-definition and type inference
across `require` boundaries:
```json
{
"runtime.version": "Lua 5.4",
"runtime.path": ["?.lua", "?/init.lua"],
"workspace.library": ["/home/me/lua-libs", "/opt/shared-lua"]
}
```
List the same directories in `workspace.library` as in `LUNA_INCLUDE_PATHS`;
modules under the script's own directory are found via the workspace root.
## Example layout
```
myproject/
├── main.lua -- #!/usr/bin/env luna; symlinked to ~/.local/bin/mytool
├── .env -- LUNA_INCLUDE_PATHS=...
├── .luarc.json
└── lib/
├── helper.lua -- require "lib.helper"
└── db/
└── init.lua -- require "lib.db"
```

@ -26,11 +26,6 @@ local con = sqlite.connect(path)
string `":memory:"` for a private in-memory database that vanishes when closed. The
returned connection is an object you call methods on with `:` syntax.
Opening a file-backed database runs the [fs permission system](fs.md#permissions):
one combined read/write question for the database's directory (SQLite keeps its
WAL/journal files next to the file). In-memory databases touch no file and are
always allowed, even under `--no-fs` / `--sandbox`.
The connection closes automatically when it is garbage-collected. Call
[`con:close()`](#conclose) to release it eagerly.

@ -1,6 +1,6 @@
# `table`
Luna keeps Lua's standard `table` library (`insert`, `remove`, `concat`, `sort`,
`a` keeps Lua's standard `table` library (`insert`, `remove`, `concat`, `sort`,
`pack`, `unpack`) and adds the higher-order and collection helpers stock Lua
leaves out: map/filter/reduce, merge and deep-copy, aggregates, lookups, and a
read-only proxy. The additions live on the same global `table`.

@ -1,546 +0,0 @@
# `tui`
The `tui` global is for simple terminal interactivity: asking the user
questions (**prompts**), producing colored, styled output (**`tui.styled`**),
and printing ready-made **message blocks** (`tui.success`, `tui.outlineError`,
…). Prompts render an interactive one-line UI on the terminal — a yes/no
question, a text field, a filterable option list, a calendar — and return the
answer as a plain Lua value. It is always available as the global `tui`.
```lua
local name = tui.promptText("Your name?")
local lang = tui.promptSelect("Favorite language?", "lua", { options = { "lua", "rust", "c" } })
if tui.confirm("Save results?", true) then
print(tui.styled("<info>saved</info> for <options=bold>" .. tui.escape(name) .. "</>"))
end
```
## Prompts
All prompts share one shape:
```lua
tui.promptXxx(text, default, opts)
```
- `text` — the question shown to the user (required string).
- `default` — the prompt's default value; `nil` means none. (Exceptions:
`promptSelect` matches the default against the options *by value*;
`promptSecret` has no default parameter at all.)
- `opts` — an optional table of extended options, listed per prompt below.
Every prompt accepts `help` (a hint line shown below the prompt). Unknown
keys are an error, so typos fail loudly.
Shared behavior:
- **Esc returns `nil`.** Declining a prompt is a normal, checkable outcome —
this holds for `tui.confirm` too, since `false` ("answered no") is distinct
from `nil` ("didn't answer").
- **Ctrl-C raises an error** (`tui.<fn>: interrupted`), stopping the script
unless trapped with `pcall`/`utils.try`.
- **A terminal is required.** Prompting with stdin/stdout redirected raises
`tui.<fn>: not a terminal`.
- **Prompts are async.** Like `os.sleep` or `http`, a prompt suspends only the
calling coroutine (see [Concurrency](concurrency.md)). Prompts from
concurrent `task.join` branches are serialized — one owns the terminal at a
time — but `print`/`log` output from sibling coroutines can still interleave
visually with a live prompt.
- **Validation options re-prompt inline.** Constraint options like `min`,
`maxLen` or `minSelected` are enforced by the prompt UI itself (an inline
message, then the user tries again) — they do not raise Lua errors.
Malformed *arguments* (a default that is not among the options, `min > max`,
a bad date string) raise immediately, before the terminal is touched.
### `tui.confirm(text, default, opts)``boolean | nil`
Yes/no question, answered with `y`/`n`.
```lua
local ok = tui.confirm("Deploy to production?", false, { help = "y/N" })
if ok == nil then return end -- Esc: user backed out
```
- `default` — boolean returned when the user just presses Enter; with `nil`
the user must type an answer.
- opts: `help`, `placeholder`.
### `tui.promptText(text, default, opts)``string | nil`
One-line text input.
```lua
local host = tui.promptText("Host?", "localhost", {
placeholder = "hostname or IP",
suggestions = { "localhost", "db.internal", "cache.internal" },
})
```
- `default` — pre-filled **editable** text (the user edits it in place).
- opts:
- `placeholder` — dim hint shown while the input is empty.
- `default` — string returned when the input is submitted empty (shown as a
hint; distinct from the pre-filled positional default, and both can be
used together).
- `minLen`, `maxLen` — length bounds (characters), enforced inline.
- `suggestions` — array of strings offered as autocompletion
(case-insensitive substring match; Tab completes the highlighted one).
- `pageSize` — how many suggestions are visible at once.
### `tui.promptLongText(text, default, opts)``string | nil`
Multi-line input via an external editor: opens `$VISUAL`/`$EDITOR` (falling
back to a platform default) on a temp file and returns its contents on save.
```lua
local body = tui.promptLongText("Release notes", "## Changes\n\n", { extension = ".md" })
```
- `default` — text the editor buffer starts with.
- opts:
- `extension` — temp-file extension (drives editor syntax highlighting);
a missing leading dot is added, default `.txt`.
- `editor` — editor command to use instead of `$VISUAL`/`$EDITOR`.
### `tui.promptSelect(text, default, opts)``string | string[] | nil`
Pick from a list. The third argument is **required** — it carries the options.
The list is filterable by typing (fuzzy match); arrows or `vimMode` keys move
the cursor.
```lua
local color = tui.promptSelect("Color?", "green", { options = { "red", "green", "blue" } })
local picks = tui.promptSelect("Toppings?", { "cheese" }, {
options = { "cheese", "ham", "mushrooms", "olives" },
multiple = true,
minSelected = 1,
})
```
- `default` — matched against the options **by value**; a value that is not
among the options raises an error (a stale default is a script bug). Single
mode: a string (the cursor starts there). Multiple mode: a string or an
array of strings (pre-checked).
- opts:
- `options` — non-empty array of strings (**required**).
- `multiple``true` switches to checkbox-style multi-select (Space
toggles, Enter submits) and the result becomes an array of the chosen
strings (possibly empty).
- `pageSize` — visible rows (default 7).
- `vimMode``hjkl` navigation.
- `filter` — set `false` to disable the type-to-filter input.
- multiple-only: `minSelected` / `maxSelected` (selection-count bounds,
enforced inline), `allSelected` (start with everything checked).
### `tui.promptSecret(text, opts)``string | nil`
Secret input. Deliberately has **no default parameter**.
```lua
local secret = tui.promptSecret("API token", { confirm = true, minLen = 8 })
```
- opts:
- `confirm` — ask twice and require both entries to match (default `false`).
- `display``"masked"` (default, `*` per character), `"hidden"` (no echo
at all), or `"full"` (plain text).
- `toggle` — allow Ctrl-R to toggle revealing the input.
- `minLen` — minimum length, enforced inline.
### `tui.promptNumber(text, default, opts)``number | nil`
### `tui.promptInt(text, default, opts)``integer | nil`
Numeric input; the entry is parsed and re-prompted until it is a valid number
(`promptNumber`) or whole number (`promptInt`).
```lua
local ratio = tui.promptNumber("Ratio?", 0.5, { min = 0, max = 1 })
local port = tui.promptInt("Port?", 8080, { min = 1, max = 65535 })
```
- `default` — returned when the input is submitted empty. `promptInt`
rejects a fractional default (`1.5`) at call time.
- opts: `placeholder`, `min`, `max` (bounds enforced inline; `min > max` is a
call-time error).
### `tui.promptDate(text, default, opts)``string | nil`
Date picker: an interactive calendar navigated with the arrow keys. Dates
cross the API as `"YYYY-MM-DD"` strings in both directions.
```lua
local day = tui.promptDate("Start date?", "2026-07-06", {
min = "2026-01-01",
max = "2026-12-31",
weekStart = "monday",
})
```
- `default` — the initially selected date, `"YYYY-MM-DD"`.
- opts:
- `min`, `max` — selectable range, `"YYYY-MM-DD"`.
- `weekStart` — first day of the calendar week: a weekday name like
`"monday"` or `"sun"` (default Sunday).
## `tui.styled(markup)``string`
Renders text with HTML-like style tags into a string with ANSI escape
sequences, ready to `print`. The tag syntax follows
[Symfony console formatting](https://symfony.com/doc/current/console/coloring.html).
```lua
print(tui.styled("<error> FAIL </error> expected <info>42</info>, see " ..
"<href=https://example.com/docs>the docs</> for <fg=#8888ff;options=bold>details</>"))
```
### Built-in styles
| Tag | Rendering |
|-----|-----------|
| `<info>` | bold green text |
| `<error>` | white on red |
| `<b>` | **bold** |
| `<i>` | *italic* |
| `<u>` | underscore |
| `<s>` | ~~strikethrough~~ |
(Symfony's `<comment>` and `<question>` are deliberately not built in — add
them with `tui.addPreset` if you want them.)
### Inline styles
`<fg=COLOR>`, `<bg=COLOR>` and `<options=...>` can be combined in one tag,
separated by `;`:
```lua
tui.styled("<fg=green>ok</> <bg=yellow;options=bold>careful</> <fg=#c0392b>brick red</>")
```
- Colors: `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`,
`white`, `gray`, their `bright-*` variants, `default` (the terminal's own
color — as an *inner* tag it resets an inherited color), and hex
`#rrggbb` / `#rgb` (truecolor).
- Options (comma-separated): `bold`, `italic`, `underscore`, `blink`,
`reverse`, `conceal`, `strikethrough`.
- The syntax is strict: no spaces around `=` or `;`.
### Shorthand
Bare tokens work without the `fg=`/`options=` keys: a color names the
foreground, `on-<color>` the background, and option names apply directly.
Shorthand mixes freely with `key=value` parts and built-in names:
```lua
tui.styled("<red;on-white;bold>alarm</> <#f0a;u>pink underline</> <info;b>bold green</>")
```
### Custom presets — `tui.addPreset(name, style)`
Register your own tag name for any style spec. The spec uses the same syntax
as tags (attributes, shorthand, or another preset/built-in name); the name
must be identifier-like (start with a letter; letters, digits, `-`, `_`).
```lua
tui.addPreset("keyword", "fg=white;bg=magenta")
tui.addPreset("em", "i;bold")
print(tui.styled("the <keyword>local</keyword> keyword is <em>important</em>"))
tui.note("see the <keyword>docs</keyword>") -- NOT styled: block text is plain
tui.block("styled block", { style = "keyword" }) -- but block styles resolve presets
```
Re-registering a name overwrites it, and a preset may shadow a built-in name
or shorthand token (e.g. `addPreset("info", "fg=blue")` restyles `<info>`).
### Closing tags and nesting
`</>` closes the innermost open style; named forms (`</info>`,
`</fg=green>`) work too and do the same thing. Styles nest — an inner tag
overrides only what it sets and inherits the rest:
```lua
tui.styled("<error>fail in <options=bold>step 2</> of job</error>")
-- "step 2" is bold white-on-red; the rest of the sentence plain white-on-red
```
### Hyperlinks
`<href=URL>` emits an [OSC-8 hyperlink](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda);
supporting terminals show clickable text, others show just the text:
```lua
tui.styled("see <href=https://example.com>the manual</>")
```
### Action tags: cursor movement and clearing
Besides styles, markup can move the cursor and clear parts of the display —
handy for single-line redraws without string-formatting escape codes by hand:
```lua
for i = 1, 100 do
tui.raw(tui.styled(("<clear=line>progress <info>%d%%</info>"):format(i)))
-- ... work ...
end
tui.raw("\n")
```
| Tag | Effect |
|-----|--------|
| `<up>` `<down>` `<left>` `<right>` | move the cursor by one cell |
| `<up=3>` … | move by *n* cells |
| `<row=3;col=2>` | absolute move, 1-based; each axis works alone too |
| `<start>` | column 1 of the current line |
| `<home>` | top-left corner |
| `<clear=line>` | clear the whole line, cursor to column 1 |
| `<clear=start>` | clear to the start of the line, cursor to column 1 |
| `<clear=end>` | clear to the end of the line, cursor stays put |
| `<clear=screen>` | clear the whole screen, cursor home |
| `<clear=up>` | clear above the cursor, cursor home |
| `<clear=down>` | clear below the cursor, cursor stays put |
Unlike style tags, action tags emit their escape sequence *in place* — they
don't enclose text and have no closing form (`</up>` is literal text). Ops
combine in one tag, applied left to right: `<up;clear=line>` moves up one
line and blanks it. Whole and to-start clears also return the cursor (column
1 or home, matching `tui.clearLine`/`tui.clearScreen`), so the redraw starts
from a known spot; the `end`/`down` variants leave it in place.
Like all escape output, action tags are dropped when colors are off (pipes,
`NO_COLOR`) — piped output never contains control bytes. A custom preset may
shadow an action name (`addPreset("up", …)` turns `<up>` into a style tag).
Stateful operations — the alternate screen, cursor visibility, window title —
are deliberately **not** tags; they need cleanup tracking and live in the
[terminal control functions](#terminal-control) below.
### Escaping and invalid tags
A backslash escapes a literal bracket: `"\\<info>"` prints `<info>`; `"\\\\"`
prints a single backslash. Anything that does not parse as a tag —
`<unknown>`, `<Info>` (tags are case-sensitive), a lone `<`, a bad color — is
passed through as literal text, never an error. For dynamic text, don't
escape by hand — use [`tui.escape`](#tuiescapetext--string).
### Color detection
When output is piped or `NO_COLOR` is set, `tui.styled` returns the plain
text: tags are still consumed, but no escape codes (colors or hyperlinks) are
emitted. Detection follows the usual conventions (`NO_COLOR`, `CLICOLOR`,
`CLICOLOR_FORCE`, tty check on stdout).
## `tui.escape(text)``string`
Backslash-escapes the markup metacharacters (`<`, `>`, `\`) so text from
outside the program — user input, file contents, API responses — can be
embedded in a `tui.styled` format string and come out exactly as it went in,
styled only by the enclosing tags:
```lua
local city = tui.promptText("City?")
print(tui.styled(("looking up <b>%s</b>…"):format(tui.escape(city))))
```
Without escaping, a user who types `<error>` gets it rendered as a red block,
and stray brackets can eat the format string's own tags. Only `tui.styled`
markup needs this; block messages (`tui.warning` & co.) are plain text and
never interpret tags.
## Message blocks
Ready-made status messages in the style of Symfony's console: a labeled,
word-wrapped block, padded to the terminal width (capped at 120 columns) and
painted with a background color. They **print directly to stdout**, surrounded
by blank lines. When output is piped (or `NO_COLOR` is set) they degrade to
plain aligned text.
```lua
tui.success("Deployment finished")
tui.warning({ "Disk usage above 80%", "Consider pruning old artifacts" })
tui.error("Build failed")
```
| Function | Label | Style |
|---|---|---|
| `tui.success(message, opts)` | `[OK]` | black on green |
| `tui.error(message, opts)` | `[ERROR]` | white on red |
| `tui.warning(message, opts)` | `[WARNING]` | black on yellow |
| `tui.caution(message, opts)` | `[CAUTION]` | white on red, `!` prefix |
| `tui.info(message, opts)` | `[INFO]` | green text |
| `tui.note(message, opts)` | `[NOTE]` | yellow text, `!` prefix |
| `tui.comment(message, opts)` | — | gray text |
| `tui.block(message, opts)` | — | generic: everything from opts |
- `message` — a string or an array of strings (rendered into one block,
separated by a blank line). Treated as **plain text**: markup tags are not
interpreted here (compose with `tui.styled` on your own lines instead), and
lines wrap by word to fit the width.
- `opts` (each overrides the preset's default):
- `label` — the `[LABEL]` text; continuation lines align under it.
- `style` — same syntax as a `styled` tag body: a preset or built-in name
(`"error"`, anything from `tui.addPreset`), a spec (`"fg=black;bg=cyan"`),
or shorthand (`"black;on-cyan"`). An invalid style is an error.
- `prefix` — string prepended to every line (default `" "`).
- `padding` — blank styled line above and below the text (only applies when
colors are on; without a background it would just be empty lines).
### Outline blocks
The same messages as a bordered box — colored **borders** instead of colored
backgrounds, which stays readable on any terminal color scheme:
```
┌─ Success ──────────────────────────┐
│ │
│ All tests passed │
│ │
└────────────────────────────────────┘
```
```lua
tui.outlineSuccess("All tests passed")
tui.outlineError("Build failed", { title = "Compile error" })
tui.outlineBlock("Anything", { title = "Stats", style = "fg=magenta" })
```
| Function | Fallback title | Border style |
|---|---|---|
| `tui.outlineSuccess` | Success | green |
| `tui.outlineError` | Error | red |
| `tui.outlineWarning` | Warning | yellow |
| `tui.outlineNote` | Note | blue |
| `tui.outlineInfo` | Info | green |
| `tui.outlineCaution` | Caution | red |
| `tui.outlineBlock` | — | — (generic) |
- `opts`: `title` (shown in the top border; overrides the preset's fallback),
`style` (border color, tag-body syntax), `padding` (blank line above/below
the content **inside** the box, default `true`).
## Low-level output
### `tui.raw(...)`
Writes its arguments to stdout with **no newline and no separator** (each is
converted with `tostring`), then flushes. This is the building block for
progress-style output that redraws one line:
```lua
for i = 1, 100 do
tui.raw("\rprocessing ", i, "/100")
-- ... work ...
end
tui.raw("\n")
```
### `tui.screenInfo()``table`
Facts about the terminal, for scripts that render their own UI (progress
bars, right-aligned text, …):
| Field | Meaning |
|---|---|
| `width`, `height` | terminal size in cells; `nil` when there is no terminal to measure |
| `tty` | whether stdout is a terminal (`false` when piped) |
| `colors` | whether styled output will actually emit colors (same detection as `tui.styled`) |
## Terminal control
Direct terminal manipulation for scripts that render their own UI: cursor
movement, clearing, the alternate screen, scroll regions, the window title
and the clipboard. These are the standard xterm-style escape sequences,
wrapped as functions.
```lua
tui.altScreen(true)
tui.clearScreen()
tui.showCursor(false)
tui.moveTo(2, 4)
tui.raw(tui.styled("<info>fullscreen!</info>"))
os.sleep(2)
-- no explicit restore needed: the runtime cleans up on exit
```
Shared behavior:
- **No-ops without a terminal.** When stdout is not a tty, every function
validates its arguments and then does nothing — piped output never
contains control bytes. This is a plain tty check (unlike color
detection): `NO_COLOR` turns off colors, not cursor control.
- **Bad arguments always raise**, tty or not — typos fail loudly in pipes
and CI too.
- **Automatic cleanup.** The runtime tracks the alternate screen, a hidden
cursor, a changed cursor style, a scroll region and a pushed window title,
and restores all of them when the script ends — on normal exit, on error
(the message prints on the normal screen), and on a crash. Scripts should
still restore what they change for mid-script tidiness, but cannot wreck
the terminal by forgetting.
### Cursor
- **`tui.moveTo(row, col)`** — absolute move, 1-based (row 1, column 1 is
the top-left corner). Passing `nil` for one axis keeps it:
`tui.moveTo(5)` jumps to row 5, `tui.moveTo(nil, 1)` to column 1.
- **`tui.move(dx, dy)`** — relative move: positive `dx` right, positive `dy`
down; negative values go the other way, `nil` counts as 0. The terminal
clamps at the screen edges.
- **`tui.saveCursor()` / `tui.restoreCursor()`** — save and restore the
cursor position (one slot; a save overwrites the previous one).
- **`tui.cursorPos()``row, col`** — ask the terminal where the cursor is
(1-based). Returns `nil` when there is no terminal or it doesn't answer.
Like the prompts this is async and serialized against them, so a
concurrent prompt can't eat the terminal's reply.
- **`tui.showCursor(visible)`** — hide (`false`) or show (`true` or no
argument) the cursor. A hidden cursor is re-shown on exit.
- **`tui.cursorStyle(style, opts)`** — set the cursor shape: `"block"`,
`"underline"` or `"bar"`, with `{ blink = true }` for the blinking
variant. No arguments reset to the terminal default (also done on exit).
### Clearing
- **`tui.clearLine(mode)`** / **`tui.clearScreen(mode)`** — erase the
current line / the screen. `mode` selects what: `0` or `nil` = whole,
`-1` = up to the cursor, `1` = from the cursor to the end. Whole and
to-start clears also return the cursor to a known spot (column 1 / home);
to-end clears leave it in place.
### Alternate screen
- **`tui.altScreen(on)`** — switch to (`true`) or back from (`false`) the
alternate screen buffer, the full-screen-app mode where the normal
scrollback stays untouched. Switching in an already-active direction is
ignored, and the runtime always switches back on exit.
### Scrolling
- **`tui.scrollRegion(top, bottom)`** — restrict scrolling to the given
rows (1-based, inclusive; `top < bottom`) — the basis for fixed
headers/footers. Setting a region moves the cursor home. With no arguments
the region resets to the full screen (also done on exit).
- **`tui.scroll(n)`** — scroll the scroll region: positive `n` scrolls the
content up by `n` lines, negative down, `0` does nothing. The cursor does
not move.
### Terminal integration
- **`tui.setTitle(text)`** — set the terminal window title. The first call
saves the current title on the terminal's title stack, and the runtime
restores it on exit. Control characters in the title are an error.
- **`tui.setClipboard(text)`** — copy `text` into the system clipboard via
the terminal (OSC 52). Write-only: reading the clipboard is disabled by
most terminals for good reasons. Works over SSH in supporting terminals;
some terminals require enabling clipboard access in their settings.
- **`tui.reset()`** — soft-reset the terminal (DECSTR): un-sticks stuck
attributes, a garbled charset, a forgotten scroll region — without
clearing the screen. The heavy-handed fixer for "my output looks insane".
## Notes
- **Always loaded.** No `require`; `tui` is a global in every script
(`require "tui"` also works and returns the same table).
- **Prompts need a terminal.** In pipes, cron, CI etc. every prompt raises
`not a terminal`; `tui.styled` and the blocks still work and degrade to
plain text, and the terminal-control functions become no-ops.
- **Progress bars** can be built from `tui.raw` + `tui.screenInfo` + the
`<clear=line>` action tag; a ready-made widget may come later.
- **Key events** (raw keyboard input, mouse tracking) are not exposed yet;
prompts are the supported way to read input.

@ -10,7 +10,7 @@ local data = utils.fromJSON('{"name":"Alice","age":30}')
print(data.name, data.age) --> Alice 30
print(utils.toJSON({1, 2, 3})) --> [1,2,3]
print(utils.dump({a = 1, b = {2, 3}})) --> {a=1, b={2, 3}}
print(utils.dump({a = 1, b = {2, 3}})) --> { a = 1, b = { 2, 3 } }
```
## JSON

@ -3,9 +3,7 @@
--
-- Run with: cargo run -- lua/coroutines-demo.lua
local function banner(s)
print("\n=== " .. s .. " ===")
end
local function banner(s) print("\n=== " .. s .. " ===") end
----------------------------------------------------------------------
banner("1. Pure Lua coroutines (no async involved)")
@ -51,9 +49,8 @@ end)
local t1 = os.microtime()
local first = sleeper() -- returns immediately with the sentinel
print(
string.format("first resume returned %s after only %.3fs (did NOT wait 0.5s)", tostring(first), os.microtime() - t1)
)
print(string.format("first resume returned %s after only %.3fs (did NOT wait 0.5s)",
tostring(first), os.microtime() - t1))
print("--> lesson: don't hand-drive coroutines that call async stdlib functions.")
----------------------------------------------------------------------
@ -72,34 +69,31 @@ local function worker(name, secs)
end
local t2 = os.microtime()
local a, b, c = task.join(worker("slow", 0.30), worker("med", 0.20), worker("fast", 0.10))
local a, b, c = task.join(
worker("slow", 0.30),
worker("med", 0.20),
worker("fast", 0.10)
)
local elapsed = os.microtime() - t2
print(string.format("results: %s, %s, %s", a, b, c))
print(string.format("wall time: %.3fs", elapsed))
print(
string.format(
"sequential would have been ~0.60s; concurrent ~0.30s => %s",
elapsed < 0.45 and "CONCURRENT (overlapped on tokio)" or "serialized?!"
)
)
print(string.format("sequential would have been ~0.60s; concurrent ~0.30s => %s",
elapsed < 0.45 and "CONCURRENT (overlapped on tokio)" or "serialized?!"))
----------------------------------------------------------------------
banner("5. Nested + return values")
----------------------------------------------------------------------
-- task.join itself yields, so it composes: a joined task can join again.
local outer = task.join(function()
local x, y = task.join(function()
os.sleep(0.05)
return 21
end, function()
os.sleep(0.05)
return 21
end)
local outer = task.join(
function()
local x, y = task.join(
function() os.sleep(0.05); return 21 end,
function() os.sleep(0.05); return 21 end
)
return x + y
end, function()
os.sleep(0.10)
return "sibling"
end)
end,
function() os.sleep(0.10); return "sibling" end
)
print("nested join result:", outer) -- 42
print("\nAll demos finished.")

@ -1,36 +0,0 @@
#!/usr/bin/env luna
-- Showcase os.args(): the command-line arguments passed to the script.
--
-- Try:
-- luna lua/demo/args.lua greet --name=World extra
-- luna --sandbox lua/demo/args.lua -- greet --name=World
-- or make the file executable and call it directly (the shebang works too):
-- chmod +x lua/demo/args.lua
-- ./lua/demo/args.lua greet --name=World
local args = os.args()
print("os.args() = " .. utils.dump(args))
if #args == 0 then
print("no arguments given — try: luna lua/demo/args.lua greet --name=World extra")
return
end
-- A tiny argument parser: --key=value pairs become options, the rest positionals.
local opts, positional = {}, {}
for _, arg in ipairs(args) do
local key, value = arg:match("^%-%-([%w-]+)=(.*)$")
if key then
opts[key] = value
else
table.insert(positional, arg)
end
end
print("options = " .. utils.dump(opts))
print("positionals = " .. utils.dump(positional))
if positional[1] == "greet" then
print(("Hello, %s!"):format(opts.name or "stranger"))
end

@ -1,124 +0,0 @@
-- The Mandelbrot set rendered as colored ASCII art, drawn row by row with
-- the tui positioning tools (alternate screen, moveTo, clearScreen), then a
-- short animated zoom into "seahorse valley". When output is piped, it
-- degrades to a single plain-text render.
--
-- Run with: cargo run -- lua/demo/mandelbrot.lua
-- One character + color per escape-speed bucket, slowest to fastest.
local CHARS = { ".", ":", "-", "=", "+", "*", "#", "%", "@" }
local COLORS = {
"#1e3a8a", "#1d4ed8", "#0284c7", "#0d9488", "#16a34a",
"#84cc16", "#eab308", "#f97316", "#ef4444",
}
--- How many iterations until the point escapes, or nil if it stays bounded
--- (i.e. belongs to the set).
local function escapeTime(cx, cy, maxIter)
local x, y = 0, 0
for i = 1, maxIter do
local x2, y2 = x * x, y * y
if x2 + y2 > 4 then
return i
end
y = 2 * x * y + cy
x = x2 - y2 + cx
end
return nil
end
--- Render one row as a styled string, batching same-colored runs into a
--- single tag so the escape-code overhead stays small.
local function renderRow(cy, xMin, dx, cols, maxIter)
local parts, run, runColor = {}, {}, nil
local function flush()
if #run == 0 then
return
end
local text = table.concat(run)
parts[#parts + 1] = runColor and ("<%s>%s</>"):format(runColor, text) or text
run = {}
end
for col = 0, cols - 1 do
local i = escapeTime(xMin + col * dx, cy, maxIter)
local ch, color = " ", nil -- inside the set: leave the cell empty
if i then
local idx = math.clamp(math.floor(math.sqrt(i / maxIter) * #CHARS) + 1, 1, #CHARS)
ch, color = CHARS[idx], COLORS[idx]
end
if color ~= runColor then
flush()
runColor = color
end
run[#run + 1] = ch
end
flush()
return tui.styled(table.concat(parts))
end
--- Draw one full frame. Interactively each row is placed with tui.moveTo
--- (row 1 is a header line); piped, rows are just printed in order.
local function drawFrame(cx, cy, scale, maxIter, cols, rows, interactive)
-- Terminal cells are roughly twice as tall as they are wide, so step
-- twice as far in y to keep the set round.
local dx = scale / cols
local dy = dx * 2
local xMin = cx - dx * cols / 2
local yMin = cy - dy * rows / 2
if interactive then
tui.moveTo(1, 1)
tui.raw(tui.styled(
("<clear=line><b>Mandelbrot</b> center %.9f%+.9fi width %.2e max iter %d")
:format(cx, cy, scale, maxIter)
))
end
for row = 1, rows do
local line = renderRow(yMin + (row - 1) * dy, xMin, dx, cols, maxIter)
if interactive then
tui.moveTo(row + 1, 1)
tui.raw(line)
else
print(line)
end
end
end
tui.setTitle("mandelbrot")
local info = tui.screenInfo()
local cols = info.width or 100
local rows = (info.height or 32) - 2 -- header + one spare line for the cursor
-- The classic full view: x roughly [-2.1, 1.1].
local fullCx, fullCy, fullScale = -0.5, 0, 3.2
if not info.tty then
drawFrame(fullCx, fullCy, fullScale, 80, cols, rows, false)
return
end
tui.altScreen(true)
tui.clearScreen()
tui.showCursor(false)
drawFrame(fullCx, fullCy, fullScale, 80, cols, rows, true)
os.sleep(2)
-- Zoom into "seahorse valley" between the main cardioid and the period-2
-- bulb, cranking up the iteration budget as detail gets finer.
local cx, cy = -0.743643887, 0.131825904
local scale = fullScale
for frame = 1, 40 do
scale = scale * 0.85
drawFrame(cx, cy, scale, 60 + frame * 8, cols, rows, true)
os.sleep(0.03)
end
os.sleep(2)
tui.altScreen(false)
tui.showCursor(true)
tui.success(("zoomed to a window %.2e units wide — that's about %dx magnification"):format(
scale,
math.round(fullScale / scale)
))

@ -1,61 +0,0 @@
-- Smoke-test for the stdlib extensions.
print("=== math ===")
print("round(2.5) =", math.round(2.5)) -- 3
print("round(2.567,2) =", math.round(2.567, 2)) -- 2.57
print("clamp(10,0,5) =", math.clamp(10, 0, 5)) -- 5
print("isFinite(1/0) =", math.isFinite(1 / 0)) -- false
print("isFinite(3.14) =", math.isFinite(3.14)) -- true
print("sign(-7) =", math.sign(-7)) -- -1
print("scale(5,0,10,0,100) =", math.scale(5, 0, 10, 0, 100)) -- 50.0
print("\n=== table ===")
local t = { 3, 1, 4, 1, 5 }
print("sum =", table.sum(t)) -- 14
print("min =", table.min(t)) -- 1
print("max =", table.max(t)) -- 5
print("mean =", table.mean(t)) -- 2.8
local evens = table.ifilter(t, function(v)
return v % 2 == 0
end)
print("evens =", table.concat(evens, ",")) -- 4
local doubled = table.map({ 1, 2, 3 }, function(v)
return v * 2
end)
print("doubled=", table.concat(doubled, ",")) -- 2,4,6
print("deepCopy ok:", table.deepCopy({ x = { y = 1 } }).x.y == 1)
print("merged =", table.concat(table.merge({ 1, 2 }, { 3, 4 }), ",")) -- 1,2,3,4
print("\n=== utils ===")
print("dump(nil) =", utils.dump(nil))
print("dump({1,2}) =", utils.dump({ 1, 2 }))
print("dump({x=1}) =", utils.dump({ x = 1 }))
print("isNull(NULL) =", utils.isNull(utils.NULL))
print("isNull(nil) =", utils.isNull(nil))
local json = utils.toJSON({ name = "Alice", age = 30, tags = { "a", "b" } })
print("toJSON =", json)
local parsed = utils.fromJSON(json)
print("fromJSON name=", parsed.name, "age=", parsed.age)
local v, err = utils.try(function()
return 42
end)
print("try ok: v=", v, "err=", err)
local v2, err2 = utils.try(function()
error("oops")
end)
print("try err: v=", v2, "err ~= nil:", err2 ~= nil)
print("\n=== os ===")
local t1 = os.microtime()
os.sleep(0.01)
local t2 = os.microtime()
print("microtime ok:", t2 > t1)
print("clock >= 0:", os.clock() >= 0)
print("\n=== log (set LUNA_LOG=info to see output on stderr) ===")
log.info("hello from log.info")
log.warn("hello from log.warn")
print("\nAll checks passed.")

@ -1,107 +0,0 @@
-- Showcase of the tui module's interactive prompts. Requires a terminal.
-- Esc returns nil from any prompt (handled below); Ctrl-C aborts the script.
--
-- Run with: cargo run -- lua/tui_prompts.lua
local function banner(s)
print("\n=== " .. s .. " ===")
end
----------------------------------------------------------------------
banner("1. confirm — yes/no")
----------------------------------------------------------------------
local go = tui.confirm("Run the whole showcase?", true, { help = "Esc anywhere skips that prompt" })
if go == nil then
print("(Esc pressed — nil means the user backed out, false means they answered no)")
elseif not go then
return
end
----------------------------------------------------------------------
banner("2. promptText — defaults, placeholder, suggestions")
----------------------------------------------------------------------
-- The positional default is pre-filled and editable in place.
local name = tui.promptText("Your name?", "ondra") or "anonymous"
-- suggestions: type to filter (case-insensitive substring), Tab completes.
-- opts.default (unlike the positional one) is only used on empty submit.
local editor = tui.promptText("Favorite editor?", nil, {
placeholder = "type to autocomplete",
suggestions = { "vim", "neovim", "emacs", "helix", "vscode", "zed" },
default = "vim",
})
-- minLen/maxLen re-prompt inline until satisfied.
local tag = tui.promptText("Release tag? (3-10 chars)", nil, { minLen = 3, maxLen = 10 })
----------------------------------------------------------------------
banner("3. promptSelect — single and multiple choice")
----------------------------------------------------------------------
-- Single: returns the chosen string; the default sets the cursor.
local lang = tui.promptSelect("Favorite language?", "lua", {
options = { "lua", "rust", "c", "python", "javascript" },
help = "type to filter the list",
})
-- Multiple: space toggles, enter submits; returns an array of strings.
local toppings = tui.promptSelect("Pizza toppings?", { "cheese" }, {
options = { "cheese", "ham", "mushrooms", "olives", "pineapple" },
multiple = true,
minSelected = 1,
pageSize = 4,
})
----------------------------------------------------------------------
banner("4. Numbers and dates")
----------------------------------------------------------------------
-- promptNumber accepts any number, promptInt only whole numbers; both
-- re-prompt inline when the input doesn't parse or is out of range.
local ratio = tui.promptNumber("Quality (0-1)?", 0.8, { min = 0, max = 1 })
local port = tui.promptInt("Port?", 8080, { min = 1, max = 65535 })
-- promptDate opens a calendar; dates are YYYY-MM-DD strings both ways.
local day = tui.promptDate("Release day?", os.date("%Y-%m-%d") --[[@as string]], {
min = os.date("%Y-01-01") --[[@as string]],
max = os.date("%Y-12-31") --[[@as string]],
weekStart = "monday",
})
----------------------------------------------------------------------
banner("5. promptSecret — hidden input")
----------------------------------------------------------------------
-- No default parameter on purpose. display: "masked" (default) shows *,
-- "hidden" echoes nothing, "full" shows the text; toggle allows Ctrl-R.
local secret = tui.promptSecret("API token?", { minLen = 4, toggle = true })
----------------------------------------------------------------------
banner("6. promptLongText — opens $EDITOR")
----------------------------------------------------------------------
local notes
if tui.confirm("Open your editor for release notes?", false) then
notes = tui.promptLongText("Release notes", "## " .. (tag or "next") .. "\n\n- ", { extension = ".md" })
end
----------------------------------------------------------------------
banner("Summary")
----------------------------------------------------------------------
local function show(k, v)
if v == nil then
v = "<gray;i>(skipped)</>"
elseif type(v) == "table" then
v = table.concat(v, ", ")
end
print(tui.styled(" <info>" .. k .. "</info>: " .. tostring(v)))
end
show("name", name)
show("editor", editor)
show("tag", tag)
show("language", lang)
show("toppings", toppings)
show("quality", ratio)
show("port", port)
show("release day", day)
show("token", secret and string.rep("*", #secret))
show("notes", notes and (#notes .. " chars"))
tui.success("Showcase finished — see lua/tui_styles.lua for the output half of the module")

@ -1,91 +0,0 @@
-- Showcase of the tui module's output features: styled markup, message
-- blocks, outline blocks, and the raw/screenInfo building blocks.
-- Non-interactive — safe to run piped (colors degrade to plain text).
--
-- Run with: cargo run -- lua/tui_styles.lua
local function banner(s)
print("\n=== " .. s .. " ===")
end
----------------------------------------------------------------------
banner("1. tui.styled — built-in tags")
----------------------------------------------------------------------
print(tui.styled("<info>info is bold green</info> and <error> error is white on red </error>"))
-- HTML-like shortcuts.
print(tui.styled("<b>bold</b> <i>italic</i> <u>underscore</u> <s>strikethrough</s>"))
----------------------------------------------------------------------
banner("2. Inline styles: fg / bg / options")
----------------------------------------------------------------------
print(tui.styled("<fg=magenta>named colors</>, <fg=bright-cyan>bright variants</>, <fg=gray>gray</>"))
print(tui.styled("<fg=#c0392b>hex truecolor #c0392b</> and <fg=#0af>short #0af</>"))
print(tui.styled("<bg=blue>background</> <options=bold>bold</> <options=italic,strikethrough>both</>"))
print(tui.styled("<fg=black;bg=green;options=bold> all combined in one tag </>"))
-- Shorthand: bare colors are the foreground, on-<color> the background,
-- option names apply directly; mixes with built-in names and key=value.
print(tui.styled("<red;on-white;bold>alarm</> <#f0a;u>pink underline</> <info;b>bold green</>"))
----------------------------------------------------------------------
banner("3. Custom presets — tui.addPreset")
----------------------------------------------------------------------
tui.addPreset("keyword", "fg=white;bg=magenta")
tui.addPreset("em", "i;bold") -- specs can use shorthand and reference other presets
print(tui.styled("the <keyword>local</keyword> keyword is <em>important</em>"))
tui.block("presets work as block styles too", { style = "keyword" })
----------------------------------------------------------------------
banner("4. Nesting, links, escaping")
----------------------------------------------------------------------
-- Inner tags override only what they set; the rest is inherited.
print(tui.styled("<error>outer <options=bold>bold inherits the red bg</> and back</error>"))
-- </> closes the innermost style; fg=default resets to the terminal color.
print(tui.styled("<fg=red>red <fg=default>terminal default</> red again</>"))
-- OSC-8 hyperlink (clickable in supporting terminals, plain text elsewhere).
print(tui.styled("read <href=https://example.com/docs>the docs</> for details"))
-- \< escapes a bracket; unknown tags pass through literally, never error.
print(tui.styled("literal \\<info> stays, <not-a-tag> too, a < b as well"))
----------------------------------------------------------------------
banner("5. Message blocks (Symfony style)")
----------------------------------------------------------------------
tui.success("Deployment finished without errors")
tui.warning({ "Disk usage above 80%", "Consider pruning old artifacts" })
tui.error("Build failed")
tui.caution("This will overwrite production data")
tui.info("14 files processed")
tui.note("Long messages word-wrap to the terminal width and continuation lines stay aligned under the label lorem ipsum dolor sit amet")
tui.comment("git push --force-with-lease")
-- The generic form: everything is configurable.
tui.block(
"Custom label, style and prefix",
{ label = "HEY", style = "fg=black;bg=cyan", prefix = " > ", padding = true }
)
----------------------------------------------------------------------
banner("6. Outline blocks (colored borders, plain content)")
----------------------------------------------------------------------
tui.outlineSuccess("All tests passed")
tui.outlineWarning("3 deprecation warnings", { title = "Heads up" }) -- opts.title overrides the preset
tui.outlineBlock({ "Generic box.", "Multiple messages are separated by a blank line." }, {
title = "Stats",
style = "fg=magenta",
})
----------------------------------------------------------------------
banner("7. tui.raw + tui.screenInfo — progress bar building blocks")
----------------------------------------------------------------------
local info = tui.screenInfo()
print("screen:", (info.width or "?") .. "x" .. (info.height or "?"), "tty:", info.tty, "colors:", info.colors)
-- A minimal in-place progress bar: \r rewinds the line, tui.raw prints
-- without a newline and flushes. Width adapts to the terminal; without a
-- tty the \r redraw trick is pointless, so print the final state once.
local width = math.clamp((info.width or 80) - 20, 10, 40)
local start = info.tty and 0 or width
for i = start, width do
local bar = string.rep("#", i) .. string.rep(".", width - i)
tui.raw("\r[", bar, "] ", math.floor(i / width * 100 + 0.5), "%")
os.sleep(0.02)
end
tui.raw("\n")

@ -1,75 +0,0 @@
-- Showcase of the tui terminal-control functions and markup action tags:
-- inline redraws, the alternate screen, absolute cursor addressing, scroll
-- regions. Needs a real terminal — piped, everything degrades to no-ops.
--
-- Run with: cargo run -- lua/tui_term.lua
tui.setTitle("tui terminal demo")
----------------------------------------------------------------------
-- 1. Inline progress: redraw one line with the <clear=line> action tag
----------------------------------------------------------------------
for i = 1, 30 do
tui.raw(tui.styled(("<clear=line>working <info>%d/30</info> %s"):format(i, ("#"):rep(i))))
os.sleep(0.03)
end
tui.raw(tui.styled("<clear=line>"))
tui.success("progress loop finished")
----------------------------------------------------------------------
-- 2. Full-screen: alternate screen + absolute cursor addressing
----------------------------------------------------------------------
tui.altScreen(true)
tui.clearScreen()
tui.showCursor(false)
local info = tui.screenInfo()
local w, h = info.width or 80, info.height or 24
tui.moveTo(2, 3)
tui.raw(tui.styled(("<info;b>alternate screen</> — %dx%d cells"):format(w, h)))
-- A little box drawn with moveTo.
for row = 4, 8 do
tui.moveTo(row, 3)
if row == 4 or row == 8 then
tui.raw("+" .. ("-"):rep(20) .. "+")
else
tui.raw("|" .. (" "):rep(20) .. "|")
end
end
tui.moveTo(6, 6)
tui.raw(tui.styled("<yellow>boxed content</>"))
tui.moveTo(10, 3)
tui.raw("where is the cursor? ")
local row, col = tui.cursorPos()
tui.raw(tui.styled(("at <b>row %s, col %s</b>"):format(tostring(row), tostring(col))))
tui.moveTo(12, 3)
tui.raw("back to the normal screen in 2 seconds...")
os.sleep(2)
tui.altScreen(false)
tui.showCursor(true)
print("back — scrollback intact.")
----------------------------------------------------------------------
-- 3. Scroll region: log lines scrolling under a fixed header
----------------------------------------------------------------------
print()
print(tui.styled("<b>FIXED HEADER</b> (lines below scroll, this one stays)"))
local top = select(1, tui.cursorPos())
if top then
tui.scrollRegion(top, top + 5)
for i = 1, 15 do
tui.moveTo(top + 5, 1)
tui.raw(("\nlog line %d"):format(i))
os.sleep(0.1)
end
tui.scrollRegion()
tui.moveTo(top + 6, 1)
end
print("scroll region reset.")
-- Deliberately no title restore: the runtime pops the pushed title (and
-- would also leave the alt screen / re-show the cursor) on exit.

@ -1,256 +0,0 @@
-- Weather forecast demo — a small app that exercises most of the Luna stdlib:
--
-- tui prompts (text w/ suggestions, select, confirm), styled markup,
-- custom presets, escape for untrusted text in markup, message &
-- outline blocks, raw output with <clear=line> redraws,
-- setTitle, setClipboard
-- http getJSON with a timeout, non-2xx handling
-- task.join a spinner animating *while* the request is in flight
-- table map / ifilter / ifilterMap / min / max / mean / concat
-- math round / clamp / scale
-- utils try, dump
-- log diagnostics on stderr (run with LUNA_LOG=info to see them)
--
-- Data comes from https://wttr.in (no API key). The app prompts for a city,
-- shows current conditions and a 3-day forecast, then lets you drill into a
-- day's hourly detail. Esc backs out of any prompt.
--
-- Run with: cargo run -- lua/weather.lua
----------------------------------------------------------------------
-- Styling: custom presets keep the markup below readable
----------------------------------------------------------------------
tui.addPreset("accent", "cyan;b")
tui.addPreset("dim", "gray")
tui.addPreset("freezing", "#7ecbff")
tui.addPreset("chilly", "#b0e0ff")
tui.addPreset("mild", "green")
tui.addPreset("warm", "yellow")
tui.addPreset("hot", "#ff5f2e;b")
-- Pick a preset name for a temperature (thresholds are °C).
local function tempPreset(celsius)
if celsius <= 0 then return "freezing"
elseif celsius <= 10 then return "chilly"
elseif celsius <= 22 then return "mild"
elseif celsius <= 30 then return "warm"
else return "hot" end
end
-- "<mild>17°C</>" — a temperature with its color; `shown` is the display
-- value (may be °F), `celsius` drives the color choice.
local function fmtTemp(shown, celsius, suffix)
local p = tempPreset(celsius)
return ("<%s>%d%s</%s>"):format(p, math.round(shown), suffix, p)
end
-- A fixed-width bar: `lo..hi` is the full scale, `a..b` the filled span.
-- math.scale maps temperatures onto columns; clamp=true guards outliers.
local function spanBar(lo, hi, a, b, width)
local from = math.round(math.scale(a, lo, hi, 1, width, true))
local to = math.round(math.scale(b, lo, hi, 1, width, true))
from, to = math.clamp(from, 1, width), math.clamp(to, 1, width)
return ("·"):rep(from - 1) .. (""):rep(to - from + 1) .. ("·"):rep(width - to)
end
-- A 0-100% meter, colored by how full it is.
local function pctBar(pct, width)
local filled = math.round(math.scale(pct, 0, 100, 0, width, true))
local style = pct >= 60 and "accent" or "dim"
return ("<%s>%s</%s><dim>%s</dim> %3d%%"):format(
style, (""):rep(filled), style, ("·"):rep(width - filled), pct)
end
----------------------------------------------------------------------
-- Fetching: task.join runs the HTTP request and a spinner concurrently
----------------------------------------------------------------------
local FRAMES = { "", "", "", "", "", "", "", "", "", "" }
local function fetchWeather(city)
local url = "https://wttr.in/" .. city:gsub(" ", "+") .. "?format=j1"
log.info("GET", url)
-- The city is user input headed for styled markup: escape it so a name
-- like "<error>" (or a stray backslash) can't derail the tags around it.
local cityLabel = tui.escape(city)
local data, err
local done = false
task.join(
function()
-- utils.try traps transport errors (DNS, TLS, timeout) instead
-- of aborting; an HTTP error status is not an error and is
-- reported through resp.status / resp.ok.
local resp
resp, err = utils.try(function()
return http.get(url, { timeout = 20 })
end)
if resp and not resp.ok then
err = ("wttr.in answered HTTP %d — probably not a place it knows"):format(resp.status)
elseif resp then
data, err = utils.try(resp.json)
end
done = true
end,
function()
-- The spinner keeps animating because http.getJSON suspends
-- instead of blocking. <clear=line> redraws in place.
local i = 1
while not done do
tui.raw(tui.styled(("<clear=line><accent>%s</accent> asking wttr.in about <b>%s</b>…")
:format(FRAMES[i], cityLabel)))
i = i % #FRAMES + 1
os.sleep(0.08)
end
tui.raw(tui.styled("<clear=line>"))
end
)
return data, err
end
----------------------------------------------------------------------
-- Rendering
----------------------------------------------------------------------
local BAR_W = 24
local function dayLabel(isoDate)
local y, m, d = isoDate:match("(%d+)-(%d+)-(%d+)")
return os.date("%a %b %e", os.time({ year = y, month = m, day = d, hour = 12 }))
end
local function showCurrent(data, T, W)
local cur = data.current_condition[1]
local area = data.nearest_area[1]
local place = ("%s, %s"):format(area.areaName[1].value, area.country[1].value)
log.debug("nearest_area:", utils.dump(area))
tui.block((" %s (%s, %s)"):format(place, area.latitude, area.longitude),
{ style = "black;on-cyan", padding = true })
local t, feels = tonumber(cur["temp_" .. T]), tonumber(cur["FeelsLike" .. T])
local tC = tonumber(cur.temp_C)
-- weatherDesc is free text from the API — escape it like any input.
print(tui.styled((" %s <b>%s</b> <dim>(feels like %s)</dim>")
:format(fmtTemp(t, tC, "°" .. T), tui.escape(cur.weatherDesc[1].value),
fmtTemp(feels, tonumber(cur.FeelsLikeC), "°" .. T))))
print(tui.styled((" wind <b>%s %s</b> %s · pressure <b>%s hPa</b> · UV <b>%s</b>")
:format(cur["windspeed" .. W], W == "Kmph" and "km/h" or "mph",
cur.winddir16Point, cur.pressure, cur.uvIndex)))
print(tui.styled(" humidity " .. pctBar(tonumber(cur.humidity), BAR_W)))
print()
end
local function showForecast(data, T)
-- Every hourly temperature across all days — table.map preserves the
-- sequence, table.min/max aggregate it — fixes one scale for the bars.
local allTemps = {}
for _, day in ipairs(data.weather) do
for _, h in ipairs(day.hourly) do
table.insert(allTemps, tonumber(h["temp" .. T]))
end
end
local lo, hi = table.min(allTemps), table.max(allTemps)
print(tui.styled((" <u>3-day forecast</u> <dim>(scale %d°%s … %d°%s)</dim>"):format(lo, T, hi, T)))
for _, day in ipairs(data.weather) do
local temps = table.map(day.hourly, function(h) return tonumber(h["temp" .. T]) end)
local rain = math.round(table.mean(
table.map(day.hourly, function(h) return tonumber(h.chanceofrain) end)) or 0)
local dMin, dMax = table.min(temps), table.max(temps)
print(tui.styled((" %-10s %s … %s <%s>%s</> rain %2d%% <dim>☀ %s → %s</dim>")
:format(dayLabel(day.date),
fmtTemp(dMin, tonumber(day.mintempC), "°"),
fmtTemp(dMax, tonumber(day.maxtempC), "°"),
tempPreset(tonumber(day.maxtempC)), spanBar(lo, hi, dMin, dMax, BAR_W),
rain, day.astronomy[1].sunrise, day.astronomy[1].sunset)))
end
print()
end
local function showHourly(day, T, W)
print(tui.styled(("\n <u>%s, hour by hour</u>"):format(dayLabel(day.date))))
for _, h in ipairs(day.hourly) do
local time = ("%04d"):format(tonumber(h.time)):sub(1, 2) .. ":00"
print(tui.styled((" <dim>%s</dim> %s %s <dim>wind %3s</dim> %-24s")
:format(time,
fmtTemp(tonumber(h["temp" .. T]), tonumber(h.tempC), "°"),
pctBar(tonumber(h.chanceofrain), 10),
h["windspeed" .. W], tui.escape(h.weatherDesc[1].value))))
end
-- table.ifilterMap: keep only the rainy hours, mapped to "HH:00".
local rainy = table.ifilterMap(day.hourly, function(h)
if tonumber(h.chanceofrain) >= 60 then
return ("%04d"):format(tonumber(h.time)):sub(1, 2) .. ":00"
end
end)
if #rainy > 0 then
tui.warning("Rain is likely around " .. table.concat(rainy, ", ") .. " — pack an umbrella.")
else
tui.success("No serious rain expected that day.")
end
end
----------------------------------------------------------------------
-- Main loop
----------------------------------------------------------------------
tui.outlineInfo("Weather forecast, courtesy of wttr.in.\nEsc backs out of any prompt; Ctrl-C quits.",
{ title = "a-weather" })
local units = tui.promptSelect("Units?", "metric", {
options = { "metric", "imperial" },
help = "°C & km/h, or °F & mph",
})
if units == nil then return end
local T = units == "metric" and "C" or "F" -- temp field suffix in wttr JSON
local W = units == "metric" and "Kmph" or "Miles" -- wind field suffix
while true do
local city = tui.promptText("City?", "Prague", {
placeholder = "any city name wttr.in knows",
suggestions = { "Prague", "Brno", "Berlin", "London", "Tokyo", "Reykjavik", "Dubai" },
})
if city == nil or city == "" then
tui.comment("Nothing to look up — bye.")
return
end
tui.setTitle("weather: " .. city) -- restored automatically on exit
local data, err = fetchWeather(city)
if not data then
tui.error({ "Weather lookup failed.", tostring(err) })
elseif not data.current_condition then
-- wttr answers 200 with an error payload for unknown places
tui.warning(("wttr.in has no forecast for %q — try another spelling."):format(city))
else
print()
showCurrent(data, T, W)
showForecast(data, T)
-- Drill into one day; options are built from the payload itself.
local labels = table.map(data.weather, function(d) return dayLabel(d.date) end)
local pick = tui.promptSelect("Hourly detail for which day?", labels[1], {
options = labels,
help = "Esc to skip",
})
if pick ~= nil then
local _, idx = table.find(labels, function(l) return l == pick end)
showHourly(data.weather[idx], T, W)
end
-- One-line summary to the clipboard (OSC 52), if the user wants it.
local cur = data.current_condition[1]
local summary = ("%s: %s°%s, %s"):format(city, cur["temp_" .. T], T, cur.weatherDesc[1].value)
if tui.confirm("Copy summary to clipboard?", false, { help = summary }) then
tui.setClipboard(summary)
tui.info("Copied: " .. summary)
end
end
print()
if not tui.confirm("Look up another city?", true) then
tui.comment("Done. (Terminal title resets on exit.)")
return
end
end

@ -5,46 +5,26 @@
-- Attach resp.json() to a response table: parses resp.body via utils.fromJSON.
local function wrap_resp(resp)
resp.json = function()
return utils.fromJSON(resp.body)
end
resp.json = function() return utils.fromJSON(resp.body) end
return resp
end
-- Wrap the stateless http.request so responses carry resp.json().
local _request = http.request
--- Send a request with an explicit method (any verb, e.g. "GET", "OPTIONS").
--- @param method string
--- @param url string
--- @param opts http.RequestOpts|nil
--- @return http.Response
http.request = function(method, url, opts)
return wrap_resp(_request(method, url, opts))
end
-- Method shorthands for the stateless module.
for _, m in ipairs({ "get", "post", "put", "patch", "delete", "head" }) do
http[m] = function(url, opts)
return http.request(m:upper(), url, opts)
end
http[m] = function(url, opts) return http.request(m:upper(), url, opts) end
end
--- GET and parse the body as JSON in one step.
--- @param url string
--- @param opts http.RequestOpts|nil
--- @return any body: The parsed JSON body
--- @return http.Response resp: The full response
function http.getJSON(url, opts)
local resp = http.get(url, opts)
return resp.json(), resp
end
--- POST with a JSON body — equivalent to setting opts.json = body.
--- @param url string
--- @param body any
--- @param opts http.RequestOpts|nil
--- @return http.Response
function http.postJSON(url, body, opts)
opts = opts or {}
opts.json = body
@ -61,24 +41,12 @@ function session_mt:request(method, url, opts)
return wrap_resp(self:_request(method, url, opts))
end
function session_mt:get(url, opts)
return self:request("GET", url, opts)
end
function session_mt:post(url, opts)
return self:request("POST", url, opts)
end
function session_mt:put(url, opts)
return self:request("PUT", url, opts)
end
function session_mt:patch(url, opts)
return self:request("PATCH", url, opts)
end
function session_mt:delete(url, opts)
return self:request("DELETE", url, opts)
end
function session_mt:head(url, opts)
return self:request("HEAD", url, opts)
end
function session_mt:get(url, opts) return self:request("GET", url, opts) end
function session_mt:post(url, opts) return self:request("POST", url, opts) end
function session_mt:put(url, opts) return self:request("PUT", url, opts) end
function session_mt:patch(url, opts) return self:request("PATCH", url, opts) end
function session_mt:delete(url, opts) return self:request("DELETE", url, opts) end
function session_mt:head(url, opts) return self:request("HEAD", url, opts) end
function session_mt:getJSON(url, opts)
local resp = self:get(url, opts)
@ -93,11 +61,6 @@ end
-- Wrap the Rust session constructor to install the metatable.
local _session = http.session
--- Create a session with its own cookie jar. With a path, the jar is
--- preloaded from that JSONL file.
--- @param path string|nil
--- @return http.Session
http.session = function(path)
return setmetatable(_session(path), session_mt)
end

@ -11,16 +11,8 @@ local function roundHalfAwayFromZero(x)
return i
end
--- @brief Round a number to the nearest integer, or to N decimal places.
---
--- round(value) and round(value, 0) return an integer; round(value, places > 0) returns a float.
--- Rounds half away from zero. Errors on non-numbers, NaN and infinity, on negative or
--- non-integer places, and (in the integer form) on results outside the Lua integer range.
--- If value * 10^places overflows, rounding cannot change the value and it is returned unchanged.
---
--- @param value number: Value to round
--- @param places number|nil: Optional number of decimal places, default 0 (must be a non-negative integer)
--- @return number: The rounded value
--- Round a number to the nearest integer, or to N decimal places.
--- Rounds half away from zero. round(value, 0) returns an integer; round(value, places > 0) returns a float.
function math.round(value, places)
if type(value) ~= "number" then
error("round() called with a non-number value")
@ -42,7 +34,7 @@ function math.round(value, places)
if places == 0 then
return value
end
return value + 0.0 -- an integer has no decimals to round, but the contract says float
return value + 0.0
end
if not math.isFinite(value) then
@ -60,113 +52,64 @@ function math.round(value, places)
local mul = 10.0 ^ places
local scaled = value * mul
if not math.isFinite(scaled) then
-- rounding cannot change a value of this magnitude
return value
end
return roundHalfAwayFromZero(scaled) / mul
end
--- @brief Clamp number to an interval, optionally half-open.
--- If both bounds are nil, just returns the value.
--- Errors if any of the params is in wrong type (value must be number, min and max must be number or nil),
--- or if min is greater than max.
--- @param value number: Value to clamp
--- @param min number|nil: Lower bound, or nil for no lower bound
--- @param max number|nil: Upper bound, or nil for no upper bound
--- @return number: Value clamped to the interval
--- Clamp a number to an interval [min, max]. Either bound may be nil (half-open).
function math.clamp(value, min, max)
if type(value) ~= "number" then
error("Non-number passed to clamp() as value")
end
if min ~= nil and type(min) ~= "number" then
error("Non-number passed to clamp() as minimum")
end
if max ~= nil and type(max) ~= "number" then
error("Non-number passed to clamp() as maximum")
end
if min ~= nil and max ~= nil and min > max then
error("clamp() minimum is greater than maximum")
end
if min ~= nil and value < min then
return min
end
if max ~= nil and value > max then
return max
end
if min ~= nil and value < min then return min end
if max ~= nil and value > max then return max end
return value
end
--- @brief Check if a value is a finite number (not NaN or infinity)
--- @param value any: Value to check
--- @return boolean: True if the value is a finite number; false for NaN, infinity and non-number values
--- Return true if value is a finite number (not NaN or infinity).
function math.isFinite(value)
if type(value) ~= "number" then
return false
end
-- NaN is the only value not equal to itself
-- math.huge represents infinity
if type(value) ~= "number" then return false end
return value == value and value ~= math.huge and value ~= -math.huge
end
--- @brief Get the sign of a number: -1 for negative, 0 for zero, 1 for positive.
--- Note: NaN has no sign and yields 0.
--- @param value number: Value to take the sign of
--- @return number: -1, 0 or 1
--- Return the sign of a number: -1, 0, or 1. NaN yields 0.
function math.sign(value)
if type(value) ~= "number" then
error("Non-number passed to sign()")
end
if value > 0 then
return 1
elseif value < 0 then
return -1
else
return 0
if value > 0 then return 1
elseif value < 0 then return -1
else return 0
end
end
--- @brief Scale a value from one range to another (linear interpolation).
--- @param value number: The input value to scale
--- @param inMin number: The minimum of the input range
--- @param inMax number: The maximum of the input range
--- @param outMin number: The minimum of the output range
--- @param outMax number: The maximum of the output range
--- @param clamp boolean|nil: If true, clamp the result to [outMin, outMax]. Default is false.
--- @return number: The scaled value
--- Scale a value linearly from [inMin, inMax] to [outMin, outMax].
--- If clamp is true, the result is clamped to the output range.
function math.scale(value, inMin, inMax, outMin, outMax, clamp)
if type(value) ~= "number" then
error("Non-number passed to scale() as value")
end
if type(inMin) ~= "number" or type(inMax) ~= "number" then
error("Non-number passed to scale() as input range")
end
if type(outMin) ~= "number" or type(outMax) ~= "number" then
error("Non-number passed to scale() as output range")
end
if inMin == inMax then
error("Input range cannot be zero (inMin == inMax)")
end
if type(value) ~= "number" then error("Non-number passed to scale() as value") end
if type(inMin) ~= "number" or type(inMax) ~= "number" then error("Non-number passed to scale() as input range") end
if type(outMin) ~= "number" or type(outMax) ~= "number" then error("Non-number passed to scale() as output range") end
if inMin == inMax then error("Input range cannot be zero (inMin == inMax)") end
local ratio = (value - inMin) / (inMax - inMin)
local result = outMin + ratio * (outMax - outMin)
if clamp then
local lo, hi = outMin, outMax
if lo > hi then
lo, hi = hi, lo
if lo > hi then lo, hi = hi, lo end
if result < lo then return lo
elseif result > hi then return hi
end
if result < lo then
return lo
elseif result > hi then
return hi
end
end
return result
end

@ -23,21 +23,18 @@ return function(Connection)
end
--- Run `fn` inside a transaction: commit on success, roll back and re-raise
--- on error. The original error is re-raised unchanged (level 0), and fn's
--- return values are passed through on success.
--- on error. The original error is re-raised unchanged (level 0).
function Connection:transaction(fn)
if type(fn) ~= "function" then
error("sqlite: transaction requires a function argument (got " .. type(fn) .. ")", 2)
end
self:begin()
local results = table.pack(pcall(fn))
if results[1] then
local ok, err = pcall(fn)
if ok then
self:commit()
return table.unpack(results, 2, results.n)
else
self:rollback()
error(err, 0)
end
-- On failure, roll back but never let a rollback error mask the original
-- one (the connection may already be closed, or no transaction active).
pcall(self.rollback, self)
error(results[2], 0)
end
end

@ -1,16 +1,5 @@
--- @brief Make a read-only view of a table.
---
--- Returns a proxy table: reads pass through to the original table, writes raise an error.
--- The original table is not modified and stays writable - changes to it show through the proxy.
--- The proxy's metatable is protected (getmetatable returns false).
---
--- Caveat: the proxy itself is empty - pairs(), next() and the # operator see no contents,
--- and the table.* helpers built on them (keys, values, contains, isEmpty, ...) treat it
--- as empty. Intended for protecting API namespace tables, not for data tables.
---
--- @param tbl table: Table to protect
--- @param name string: Optional name of the table, used in error messages
--- @return table: Read-only proxy of the table
--- Make a read-only proxy of a table. Reads pass through; writes raise an error.
--- Caveat: pairs(), next() and # see no contents (proxy is empty). Intended for API namespaces.
function table.readonly(tbl, name)
if type(tbl) ~= "table" then
error("table.readonly: argument is not a table (got " .. type(tbl) .. ")")
@ -18,132 +7,73 @@ function table.readonly(tbl, name)
if name ~= nil and type(name) ~= "string" then
error("table.readonly: name is not a string (got " .. type(name) .. ")")
end
local message = "Attempt to update a read-only table"
if name ~= nil then
message = message .. " " .. name
end
if name ~= nil then message = message .. " " .. name end
return setmetatable({}, {
__index = tbl,
__newindex = function()
-- level 2 blames the assignment in the caller's code
error(message, 2)
end,
__newindex = function() error(message, 2) end,
__metatable = false,
})
end
--- @brief: Filters a table based on a predicate, all values in the table are run through this predicate; the ones that apply stay.
--- Keys are preserved - filtering a numbered table may leave gaps, use table.ifilter for those.
--- @param tbl table: Table to filter
--- @param predicate function: The predicate/filtering function, e.g. function(v) return v <= 5 end - this keeps only values up to 5
--- @return table: Product of the operation (new table)
--- Filter a table by predicate, preserving keys. May leave gaps in a sequence table.
function table.filter(tbl, predicate)
if type(tbl) ~= "table" then
error("table.filter: table argument is not a table (got " .. type(tbl) .. ")")
end
if type(predicate) ~= "function" then
error("table.filter: predicate is not a function (got " .. type(predicate) .. ")")
end
if type(tbl) ~= "table" then error("table.filter: table argument is not a table (got " .. type(tbl) .. ")") end
if type(predicate) ~= "function" then error("table.filter: predicate is not a function (got " .. type(predicate) .. ")") end
local result = {}
for k, v in pairs(tbl) do
if predicate(v) then
result[k] = v
end
if predicate(v) then result[k] = v end
end
return result
end
--- @brief: Filters a numbered table based on a predicate, all values in the table are run through this predicate; the ones that apply stay. Produces a new numbered table without gaps.
--- @param tbl table: Numbered table to filter
--- @param predicate function: The predicate/filtering function, e.g. function(v) return v <= 5 end - this keeps only values up to 5
--- @return table: Product of the operation (new table)
--- Filter a sequence table by predicate, producing a new gapless sequence.
function table.ifilter(tbl, predicate)
if type(tbl) ~= "table" then
error("table.ifilter: table argument is not a table (got " .. type(tbl) .. ")")
end
if type(predicate) ~= "function" then
error("table.ifilter: predicate is not a function (got " .. type(predicate) .. ")")
end
if type(tbl) ~= "table" then error("table.ifilter: argument is not a table (got " .. type(tbl) .. ")") end
if type(predicate) ~= "function" then error("table.ifilter: predicate is not a function (got " .. type(predicate) .. ")") end
local result = {}
for i = 1, #tbl do
local v = tbl[i]
if predicate(v) then
result[#result + 1] = v
end
if predicate(v) then result[#result + 1] = v end
end
return result
end
--- @brief: Maps the values in the table through a mapper function, preserving keys
--- @param tbl table: Table to map
--- @param mapper function: a mapping function, e.g. function(v) return v + 1 end
--- @return table: Product of the operation (new table)
--- Map values through a function, preserving keys.
function table.map(tbl, mapper)
if type(tbl) ~= "table" then
error("table.map: table argument is not a table (got " .. type(tbl) .. ")")
end
if type(mapper) ~= "function" then
error("table.map: mapper is not a function (got " .. type(mapper) .. ")")
end
if type(tbl) ~= "table" then error("table.map: table argument is not a table (got " .. type(tbl) .. ")") end
if type(mapper) ~= "function" then error("table.map: mapper is not a function (got " .. type(mapper) .. ")") end
local result = {}
for k, v in pairs(tbl) do
result[k] = mapper(v)
end
for k, v in pairs(tbl) do result[k] = mapper(v) end
return result
end
--- @brief: Maps the values in the table through a mapper function, preserving keys. If the function returns nil, the value is dropped. May leave gaps in a numbered table!
--- @param tbl table: Table to map
--- @param mapper function: a mapping function, e.g. function(v) return v + 1 end
--- @return table: Product of the operation (new table)
--- Map values through a function, dropping nils. May leave gaps in a sequence table.
function table.filterMap(tbl, mapper)
if type(tbl) ~= "table" then
error("table.filterMap: table argument is not a table (got " .. type(tbl) .. ")")
end
if type(mapper) ~= "function" then
error("table.filterMap: mapper is not a function (got " .. type(mapper) .. ")")
end
if type(tbl) ~= "table" then error("table.filterMap: table argument is not a table (got " .. type(tbl) .. ")") end
if type(mapper) ~= "function" then error("table.filterMap: mapper is not a function (got " .. type(mapper) .. ")") end
local result = {}
for k, v in pairs(tbl) do
local res = mapper(v)
if res ~= nil then
result[k] = res
end
if res ~= nil then result[k] = res end
end
return result
end
--- @brief: Maps the values in the numbered table through a mapper function. If the function returns nil, the value is dropped. Result is a numbered table without gaps.
--- @param tbl table: Numbered table to map
--- @param mapper function: a mapping function, e.g. function(v) return v + 1 end
--- @return table: Product of the operation (new table)
--- Map a sequence through a function, dropping nils, producing a gapless sequence.
function table.ifilterMap(tbl, mapper)
if type(tbl) ~= "table" then
error("table.ifilterMap: table argument is not a table (got " .. type(tbl) .. ")")
end
if type(mapper) ~= "function" then
error("table.ifilterMap: mapper is not a function (got " .. type(mapper) .. ")")
end
if type(tbl) ~= "table" then error("table.ifilterMap: argument is not a table (got " .. type(tbl) .. ")") end
if type(mapper) ~= "function" then error("table.ifilterMap: mapper is not a function (got " .. type(mapper) .. ")") end
local result = {}
for i = 1, #tbl do
local res = mapper(tbl[i])
if res ~= nil then
result[#result + 1] = res
end
if res ~= nil then result[#result + 1] = res end
end
return result
end
--- @brief Merge tables into a new table. The sequence part (consecutive positive integer keys
--- starting at 1) of each table is appended in argument order; all other keys (including
--- non-sequence numeric keys) are copied over, values from later arguments overwriting earlier
--- ones. Not recursive: nested tables are copied by reference. Errors if an argument is not a table.
---
--- Metatable is not copied.
--- @param ... table: Tables to merge
--- @return table: Product of the operation (new table)
--- Merge tables into a new table. Sequence parts are appended in order; other keys are
--- copied with later arguments overwriting earlier ones. Not recursive; not deep.
function table.merge(...)
local result = {}
local n = 0
@ -170,27 +100,18 @@ end
local MERGE_DEPTH_LIMIT = 100
local function deepCopy(value, level)
if type(value) ~= "table" then
return value
end
if level > MERGE_DEPTH_LIMIT then
error("table.deepMerge/deepCopy recursion too deep")
end
if type(value) ~= "table" then return value end
if level > MERGE_DEPTH_LIMIT then error("table.deepMerge/deepCopy recursion too deep") end
local result = {}
for k, v in pairs(value) do
result[k] = deepCopy(v, level + 1)
end
for k, v in pairs(value) do result[k] = deepCopy(v, level + 1) end
return result
end
local function mergeRecurse(level, ...)
if level > MERGE_DEPTH_LIMIT then
error("table.deepMerge recursion too deep")
end
if level > MERGE_DEPTH_LIMIT then error("table.deepMerge recursion too deep") end
local result = {}
local n = 0
for argi = 1, select("#", ...) do
for argi = 1, select('#', ...) do
local tbl = select(argi, ...)
if type(tbl) ~= "table" then
error("table.deepMerge: argument #" .. argi .. " is not a table (got " .. type(tbl) .. ")")
@ -214,235 +135,126 @@ local function mergeRecurse(level, ...)
return result
end
--- @brief Merge tables recursively into a new table. Sequence parts are appended in argument
--- order (like table.merge); for other keys, when both sides hold tables they are merged
--- recursively, otherwise the value from the later argument overwrites the earlier one.
--- The result shares no tables with the inputs (everything adopted is deep-copied).
--- Errors if an argument is not a table, or when nesting exceeds a depth limit.
---
--- Metatables are not copied.
--- @param ... table: Tables to merge
--- @return table: Product of the operation (new table)
--- Recursively merge tables. Nested tables on both sides are merged; other values are overwritten.
--- The result shares no tables with the inputs (everything is deep-copied).
function table.deepMerge(...)
return mergeRecurse(1, ...)
end
--- @brief Make a deep copy of a table
---
--- Raises an error if the value is not a table
---
--- @param value table: Table to copy
--- @return table: Clone of the table at all levels, excluding metatables
--- Deep-copy a table at all levels (excluding metatables).
function table.deepCopy(value)
if type(value) ~= "table" then
error("table.deepCopy: argument is not a table (got " .. type(value) .. ")")
end
return deepCopy(value, 1)
end
--- @brief: Reduces all values in a table to a single value using a reducer function & initial value. Iteration order is unspecified.
--- @param tbl table: Table to reduce
--- @param reducer function: The reducer function, e.g. function(acc, v) return acc + v end - this computes the sum of all values in the table
--- @param initialValue any: Starting value of the accumulator
--- @return any: Result of the reduction
--- Reduce all values in a table to one using a function. Iteration order is unspecified.
function table.reduce(tbl, reducer, initialValue)
if type(tbl) ~= "table" then
error("table.reduce: table argument is not a table (got " .. type(tbl) .. ")")
end
if type(reducer) ~= "function" then
error("table.reduce: reducer is not a function (got " .. type(reducer) .. ")")
end
if type(tbl) ~= "table" then error("table.reduce: table argument is not a table (got " .. type(tbl) .. ")") end
if type(reducer) ~= "function" then error("table.reduce: reducer is not a function (got " .. type(reducer) .. ")") end
local accumulator = initialValue
for _, v in pairs(tbl) do
accumulator = reducer(accumulator, v)
end
for _, v in pairs(tbl) do accumulator = reducer(accumulator, v) end
return accumulator
end
--- @brief: Reduces a numbered table to a single value using a reducer function & initial value, iterating the sequence part in order. Other keys are ignored.
--- @param tbl table: Numbered table to reduce
--- @param reducer function: The reducer function, e.g. function(acc, v) return acc .. v end - this concatenates all values in order
--- @param initialValue any: Starting value of the accumulator
--- @return any: Result of the reduction
--- Reduce a sequence table to one value, iterating in order.
function table.ireduce(tbl, reducer, initialValue)
if type(tbl) ~= "table" then
error("table.ireduce: table argument is not a table (got " .. type(tbl) .. ")")
end
if type(reducer) ~= "function" then
error("table.ireduce: reducer is not a function (got " .. type(reducer) .. ")")
end
if type(tbl) ~= "table" then error("table.ireduce: table argument is not a table (got " .. type(tbl) .. ")") end
if type(reducer) ~= "function" then error("table.ireduce: reducer is not a function (got " .. type(reducer) .. ")") end
local accumulator = initialValue
for _, v in ipairs(tbl) do
accumulator = reducer(accumulator, v)
end
for _, v in ipairs(tbl) do accumulator = reducer(accumulator, v) end
return accumulator
end
--- @brief: Get the lowest of all values in a table (any table, all values are visited).
--- Returns nil if the table is empty. If any value is NaN, the result is NaN.
--- @param tbl table: Table to search
--- @return any: The lowest value, or nil if the table is empty
--- Return the minimum value in a table. Returns nil for an empty table. NaN propagates.
function table.min(tbl)
if type(tbl) ~= "table" then
error("table.min: argument is not a table (got " .. type(tbl) .. ")")
end
if type(tbl) ~= "table" then error("table.min: argument is not a table (got " .. type(tbl) .. ")") end
local min
for _, val in pairs(tbl) do
if val ~= val then
return val -- NaN propagates
end
if min == nil or val < min then
min = val
end
if val ~= val then return val end
if min == nil or val < min then min = val end
end
return min
end
--- @brief: Get the highest of all values in a table (any table, all values are visited).
--- Returns nil if the table is empty. If any value is NaN, the result is NaN.
--- @param tbl table: Table to search
--- @return any: The highest value, or nil if the table is empty
--- Return the maximum value in a table. Returns nil for an empty table. NaN propagates.
function table.max(tbl)
if type(tbl) ~= "table" then
error("table.max: argument is not a table (got " .. type(tbl) .. ")")
end
if type(tbl) ~= "table" then error("table.max: argument is not a table (got " .. type(tbl) .. ")") end
local max
for _, val in pairs(tbl) do
if val ~= val then
return val -- NaN propagates
end
if max == nil or val > max then
max = val
end
if val ~= val then return val end
if max == nil or val > max then max = val end
end
return max
end
--- @brief: Get the mean of all values in a table (any table, all values are visited).
--- Returns nil if the table is empty. If any value is NaN, the result is NaN.
--- @param tbl table: Table to average
--- @return number|nil: Mean of the values, or nil if the table is empty
--- Return the arithmetic mean of all values. Returns nil for an empty table. NaN propagates.
function table.mean(tbl)
if type(tbl) ~= "table" then
error("table.mean: argument is not a table (got " .. type(tbl) .. ")")
end
if type(tbl) ~= "table" then error("table.mean: argument is not a table (got " .. type(tbl) .. ")") end
local sum
local count = 0
for _, val in pairs(tbl) do
count = count + 1
if sum == nil then
sum = val
else
sum = sum + val
end
sum = sum and sum + val or val
end
if count == 0 then
return nil
end
return sum / count
return count > 0 and sum / count or nil
end
--- @brief: Get the sum of all values in a table (any table, all values are visited).
--- Returns 0 if the table is empty. If any value is NaN, the result is NaN.
--- @param tbl table: Table to sum
--- @return number: Sum of the values, 0 for an empty table
--- Return the sum of all values. Returns 0 for an empty table. NaN propagates.
function table.sum(tbl)
if type(tbl) ~= "table" then
error("table.sum: argument is not a table (got " .. type(tbl) .. ")")
end
if type(tbl) ~= "table" then error("table.sum: argument is not a table (got " .. type(tbl) .. ")") end
local sum = 0
for _, val in pairs(tbl) do
sum = sum + val
end
for _, val in pairs(tbl) do sum = sum + val end
return sum
end
--- @brief: Get an array of all keys in the table. Order is unspecified.
--- @param tbl table: Table to take the keys from
--- @return table: New numbered table with all keys
--- Return an array of all keys. Order is unspecified.
function table.keys(tbl)
if type(tbl) ~= "table" then
error("table.keys: argument is not a table (got " .. type(tbl) .. ")")
end
if type(tbl) ~= "table" then error("table.keys: argument is not a table (got " .. type(tbl) .. ")") end
local result = {}
for k, _ in pairs(tbl) do
result[#result + 1] = k
end
for k in pairs(tbl) do result[#result + 1] = k end
return result
end
--- @brief: Get an array of all values in the table. Order is unspecified.
--- @param tbl table: Table to take the values from
--- @return table: New numbered table with all values
--- Return an array of all values. Order is unspecified.
function table.values(tbl)
if type(tbl) ~= "table" then
error("table.values: argument is not a table (got " .. type(tbl) .. ")")
end
if type(tbl) ~= "table" then error("table.values: argument is not a table (got " .. type(tbl) .. ")") end
local result = {}
for _, v in pairs(tbl) do
result[#result + 1] = v
end
for _, v in pairs(tbl) do result[#result + 1] = v end
return result
end
--- @brief: Check if a table contains a specific value.
--- @param tbl table: Table to check
--- @param value any: Value to search for (compared with ==)
--- @return boolean: True if the table contains the given element
--- Return true if the table contains the given value (compared with ==).
function table.contains(tbl, value)
if type(tbl) ~= "table" then
error("table.contains: argument is not a table (got " .. type(tbl) .. ")")
end
if type(tbl) ~= "table" then error("table.contains: argument is not a table (got " .. type(tbl) .. ")") end
for _, v in pairs(tbl) do
if v == value then
return true
end
if v == value then return true end
end
return false
end
--- @brief: Find a table element that matches a predicate. Search order is unspecified
--- (sequence part is in practice visited first, in order).
--- @param tbl table: Table to check
--- @param predicate function: Checker function
--- @return any, any: Matching value, and its index (value, key); If not found, returns nil, nil.
--- Check the key for nil to distinguish a stored false value from "not found".
--- Find a value matching a predicate. Returns (value, key) or (nil, nil) if not found.
--- Check the returned key for nil to distinguish a stored false from "not found".
function table.find(tbl, predicate)
if type(tbl) ~= "table" then
error("table.find: table argument is not a table (got " .. type(tbl) .. ")")
end
if type(predicate) ~= "function" then
error("table.find: predicate is not a function (got " .. type(predicate) .. ")")
end
if type(tbl) ~= "table" then error("table.find: table argument is not a table (got " .. type(tbl) .. ")") end
if type(predicate) ~= "function" then error("table.find: predicate is not a function (got " .. type(predicate) .. ")") end
for k, v in pairs(tbl) do
if predicate(v) then
return v, k
end
if predicate(v) then return v, k end
end
return nil, nil
end
--- @brief: Check if a table is empty (has no elements).
--- @param tbl table: Table to check
--- @return boolean: True if empty table
--- Return true if the table has no elements.
function table.isEmpty(tbl)
if type(tbl) ~= "table" then
error("table.isEmpty: argument is not a table (got " .. type(tbl) .. ")")
end
if type(tbl) ~= "table" then error("table.isEmpty: argument is not a table (got " .. type(tbl) .. ")") end
return next(tbl) == nil
end
--- @brief: Reverse an array. Returns a new array with elements in reverse order.
--- @param tbl table: Table to reverse
--- @return table: New table with reversed order
--- Return a new array with elements in reverse order.
function table.reverse(tbl)
if type(tbl) ~= "table" then
error("table.reverse: argument is not a table (got " .. type(tbl) .. ")")
end
if type(tbl) ~= "table" then error("table.reverse: argument is not a table (got " .. type(tbl) .. ")") end
local result = {}
for i = #tbl, 1, -1 do
result[#result + 1] = tbl[i]
end
for i = #tbl, 1, -1 do result[#result + 1] = tbl[i] end
return result
end

@ -1,103 +1,81 @@
-- Lua-implemented part of the utils module, extending the global utils table.
-- The native parts of the module (dump, toJSON, fromJSON, NULL, isNull)
-- are declared in Rust before this file is loaded.
-- Lua-side of the utils module.
-- utils.NULL, utils.isNull, utils.toJSON, utils.fromJSON are provided by Rust before this runs.
-- Substituted when a callback throws a nil error value (e.g. plain `error()`),
-- so that the returned err is always non-nil on failure.
local NIL_ERROR_PLACEHOLDER = "unspecified error"
--- @brief Call a function, capturing any error - like pcall, but with Go-style returns.
---
--- local value, err = utils.try(callback)
--- local value, err = utils.try(callback, arg1, arg2) -- pcall-style shorthand
--- if err then
--- print(err)
--- else
--- -- handle value
--- end
---
--- - err is nil if the callback succeeded, otherwise the error value
--- - value is the callback's first return value (nil on error)
--- - extra arguments are passed to the callback, like with pcall
---
--- This is less confusing than pcall when the return value is actually needed.
--- The thrown error value is passed through as-is, so a table thrown with
--- error({code = 42}) stays a table. The downside is that the result can't be
--- used directly as a condition in "if" or "while" - simply use pcall there.
---
--- @param callback function: Function to call
--- @param ... any: Arguments passed to the callback
--- @return any, any: The callback's first return value and nil, or nil and the error
-- Pretty-print any Lua value as a string.
local function dumpValue(val, depth, seen)
if depth > 20 then return "..." end
local t = type(val)
if t == "nil" then
return "nil"
elseif t == "boolean" or t == "number" then
return tostring(val)
elseif t == "string" then
return string.format("%q", val)
elseif t == "table" then
if utils.isNull(val) then return "null" end
if seen[val] then return "<circular>" end
seen[val] = true
local parts = {}
local len = #val
if len > 0 then
for i = 1, len do
parts[i] = dumpValue(val[i], depth + 1, seen)
end
else
for k, v in pairs(val) do
local key = type(k) == "string" and k or ("[" .. tostring(k) .. "]")
parts[#parts + 1] = key .. " = " .. dumpValue(v, depth + 1, seen)
end
end
seen[val] = nil
if #parts == 0 then return "{}" end
return "{ " .. table.concat(parts, ", ") .. " }"
else
return "<" .. t .. ">"
end
end
--- Pretty-print any Lua value to a string.
function utils.dump(val)
return dumpValue(val, 0, {})
end
--- Call a function, capturing any error. Returns (result, nil) on success or (nil, err) on failure.
--- Extra arguments are forwarded to the callback, like pcall.
function utils.try(callback, ...)
if type(callback) ~= "function" then
error("utils.try: callback is not a function (got " .. type(callback) .. ")")
end
local ok, result = pcall(callback, ...)
if ok then
return result, nil
end
if result == nil then
result = NIL_ERROR_PLACEHOLDER
end
if ok then return result, nil end
if result == nil then result = NIL_ERROR_PLACEHOLDER end
return nil, result
end
local TRYN_MAX_COUNT = 64
--- @brief Call a function, capturing any error - like utils.try, but for N return values.
---
--- local value1, value2, err = utils.tryn(2, callback)
--- local value1, value2, err = utils.tryn(2, callback, arg1, arg2) -- pcall-style shorthand
--- if err then
--- print(err)
--- else
--- -- handle (value1, value2)
--- end
---
--- - err is nil if the callback succeeded, otherwise the error value
--- - values are the callback's return values, padded with nils or truncated to exactly N
--- - extra arguments are passed to the callback, like with pcall
---
--- This is less confusing than pcall for multiple return values, where the error
--- and the first value share a slot:
---
--- local status, errorOrValue1, value2 = pcall(callback)
---
--- @param expectedCount number: How many return values to pass through (integer, 0 to 64)
--- @param callback function: Function to call
--- @param ... any: Arguments passed to the callback
--- @return any ...: expectedCount values followed by the error or nil
--- Like utils.try but for functions that return N values.
--- Returns (v1, ..., vN, nil) on success or (nil, ..., nil, err) on failure.
function utils.tryn(expectedCount, callback, ...)
local count = math.tointeger(expectedCount)
if count == nil then
error("utils.tryn: expectedCount must be an integer number")
end
if count == nil then error("utils.tryn: expectedCount must be an integer number") end
expectedCount = count
if expectedCount < 0 then
error("utils.tryn: expectedCount must not be negative")
end
if expectedCount < 0 then error("utils.tryn: expectedCount must not be negative") end
if expectedCount > TRYN_MAX_COUNT then
error("utils.tryn: at most " .. TRYN_MAX_COUNT .. " return values are supported")
end
if type(callback) ~= "function" then
error("utils.tryn: callback is not a function (got " .. type(callback) .. ")")
end
local results = table.pack(pcall(callback, ...))
if results[1] then
-- Success: pass through values 2..expectedCount+1 (truncating or nil-padding),
-- with nil in the error slot. The explicit assignment overwrites a possible
-- extra return value so it cannot leak into the error slot.
results[expectedCount + 2] = nil
return table.unpack(results, 2, expectedCount + 2)
end
local err = results[2]
if err == nil then
err = NIL_ERROR_PLACEHOLDER
end
if err == nil then err = NIL_ERROR_PLACEHOLDER end
local out = {}
out[expectedCount + 1] = err
return table.unpack(out, 1, expectedCount + 1)

@ -1,29 +0,0 @@
# Lua stubs for the Luna runtime environment
`---@meta` type definitions of the **Rust-native** parts of the Luna stdlib, for
[lua-language-server](https://github.com/LuaLS/lua-language-server) (completion,
hover docs, static checking). Definitions only — these files are never executed
and are not part of the runtime.
The **Lua-implemented** parts of the stdlib need no stubs: `lua/stdlib/*.lua`
are the actual sources and the language server reads them directly. The split:
| Stub here | Covers |
|----------------|---------------------------------------------------------------|
| `tui.lua` | the whole `tui` module (fully native) |
| `http.lua` | `http` requests, response shape, sessions |
| `sqlite.lua` | `sqlite.connect` and the connection methods |
| `log.lua` | `log.trace``log.error` |
| `task.lua` | `task.join` |
| `utils.lua` | native half: `NULL`, `isNull`, `dump`, `toJSON`, `fromJSON` (`try`/`tryn` live in `lua/stdlib/utils.lua`) |
| `os.lua` | sandbox additions `os.microtime`, `os.sleep` |
| `require.lua` | the sandboxed `require` (the stock `package` library is disabled in `.luarc.json`) |
The sandbox itself is mirrored in the repo-root `.luarc.json`: `runtime.builtin`
disables `io`, `debug` and `package`, which do not exist in scripts.
lua-language-server cannot hide *individual* fields, so removed functions that
live inside kept libraries (`os.execute`, `os.remove`, `os.exit`, `load`,
`loadfile`, `dofile`, `string.dump`) still autocomplete — they raise at runtime,
do not use them.
Keep these files in sync with the real API; the prose reference is `docs/*.md`.

@ -1,64 +0,0 @@
---@meta
---@diagnostic disable: missing-return
-- Type stubs for the `fs` module (fully native). Reference: docs/fs.md
--
-- Every operation runs under the folder permission system: recorded grants
-- (per script, per directory, recursive) answer silently; unknown ones ask on
-- the terminal — y/n for this run, A/N recorded in access.json. Denials and
-- OS-level failures raise Lua errors; trap with pcall/utils.try where needed.
-- `--allow-fs`, `--no-fs` and `--sandbox` override the filesystem checks.
fs = {}
---Read a whole file into a string (byte-exact; no encoding assumed).
---Errors if the path is not a regular file or not accessible.
---@param path string
---@return string content
function fs.readAll(path) end
---Write a whole file from a string: created if missing, truncated if not.
---The containing directory must already exist. Errors if the file can't be
---created or is not accessible.
---@param path string
---@param content string
function fs.writeAll(path, content) end
---Names of a directory's entries (files and subdirectories), sorted.
---Errors if the path is not a directory or not accessible.
---@param path string
---@return string[] names
function fs.list(path) end
---true iff the path exists, is a directory, and is readable under the
---permission system. Never errors; a nonexistent path is false without any
---permission prompt.
---@param path string
---@return boolean
function fs.isDir(path) end
---true iff the path exists, is a regular file, and is readable under the
---permission system. Never errors; a nonexistent path is false without any
---permission prompt.
---@param path string
---@return boolean
function fs.isFile(path) end
---The process's current working directory (absolute path) — what relative
---paths in the other fs functions resolve against. Not permission-gated.
---@return string dir
function fs.getWorkDir() end
---The directory the main script really lives in (absolute path, symlinks
---resolved) — the stable base for loading assets shipped next to the script,
---independent of the CWD it was invoked from. nil in the REPL, where there
---is no script. Not permission-gated.
---@return string|nil dir
function fs.getScriptDir() end
---Run the permission cycle for a directory explicitly: recorded answer, else
---interactive prompt, else false (nothing recorded when there is no terminal
---to ask on). Errors if the path is not an existing directory.
---@param dir string
---@param mode "r"|"w"
---@return boolean granted
function fs.access(dir, mode) end

@ -1,140 +0,0 @@
---@meta
---@diagnostic disable: missing-return
-- Type stubs for the `http` module: the global table, the response/opts/session
-- classes, and the get/post/… shorthands (lua/stdlib/http.lua assigns those
-- dynamically in a loop, invisible to the language server). request, getJSON,
-- postJSON and session are NOT stubbed — they are annotated at their real
-- definitions in lua/stdlib/http.lua. Reference: docs/http.md
--
-- A non-2xx status is NOT an error (see resp.ok/resp.status); only failing to
-- get a response at all (DNS, connection, TLS, timeout) raises.
--
-- Every request runs the per-origin permission system (docs/http.md): a
-- recorded grant for scheme://host[:port] answers silently, unknown origins
-- prompt on the terminal, denials raise. `--allow-net`, `--no-net` and
-- `--sandbox` override the network checks.
http = {}
---@class http.Response
---@field status integer HTTP status code, e.g. 200
---@field ok boolean true when status is 2xx
---@field url string final URL after redirects
---@field headers table<string, string> response headers, keyed by lowercase name; repeats joined with ", " (except set-cookie: first value only, see setCookies)
---@field setCookies string[] every Set-Cookie header value on the final response
---@field body string raw response body
---@field json fun(): any parse body as JSON (JSON null becomes utils.NULL); raises on invalid JSON
---@class http.Auth
---@field username string
---@field password string
---@field scheme "basic"|"digest"? default "basic"
---@class http.RequestOpts
---@field headers table<string, string>? extra request headers
---@field body string? raw request body (set Content-Type yourself)
---@field json any? serialized to JSON; sets Content-Type: application/json
---@field form table<string, string|number>? URL-encoded; sets application/x-www-form-urlencoded
---@field cookies table<string, string>? cookies for this request (merged with the session jar; not combinable with a Cookie header)
---@field timeout number? per-request timeout in seconds (default 30)
---@field auth http.Auth? basic or digest credentials
---@param url string
---@param opts http.RequestOpts?
---@return http.Response
function http.get(url, opts) end
---@param url string
---@param opts http.RequestOpts?
---@return http.Response
function http.post(url, opts) end
---@param url string
---@param opts http.RequestOpts?
---@return http.Response
function http.put(url, opts) end
---@param url string
---@param opts http.RequestOpts?
---@return http.Response
function http.patch(url, opts) end
---@param url string
---@param opts http.RequestOpts?
---@return http.Response
function http.delete(url, opts) end
---@param url string
---@param opts http.RequestOpts?
---@return http.Response
function http.head(url, opts) end
----------------------------------------------------------------------
-- Sessions: a cookie jar carried across requests
----------------------------------------------------------------------
---@class http.Session
local Session = {}
---@param method string
---@param url string
---@param opts http.RequestOpts?
---@return http.Response
function Session:request(method, url, opts) end
---@param url string
---@param opts http.RequestOpts?
---@return http.Response
function Session:get(url, opts) end
---@param url string
---@param opts http.RequestOpts?
---@return http.Response
function Session:post(url, opts) end
---@param url string
---@param opts http.RequestOpts?
---@return http.Response
function Session:put(url, opts) end
---@param url string
---@param opts http.RequestOpts?
---@return http.Response
function Session:patch(url, opts) end
---@param url string
---@param opts http.RequestOpts?
---@return http.Response
function Session:delete(url, opts) end
---@param url string
---@param opts http.RequestOpts?
---@return http.Response
function Session:head(url, opts) end
---@param url string
---@param opts http.RequestOpts?
---@return any body
---@return http.Response resp
function Session:getJSON(url, opts) end
---@param url string
---@param body any
---@param opts http.RequestOpts?
---@return http.Response
function Session:postJSON(url, body, opts) end
---The unexpired cookies as a nested table, {domain = {name = value}}.
---@return table<string, table<string, string>>
function Session:cookies() end
---Empty the in-memory jar.
function Session:clearCookies() end
---Write the jar to a JSONL file (one cookie per line, including session cookies).
---@param path string
function Session:save(path) end
---Load a jar file, replacing the current jar contents.
---@param path string
function Session:load(path) end

@ -1,31 +0,0 @@
---@meta
-- Type stubs for the native `log` module: level-tagged diagnostics on stderr.
-- Visibility is controlled by LUNA_LOG (default: warn and error only).
-- Arguments are stringified like print and joined with tabs; use
-- utils.dump(t) to log a table's contents. Reference: docs/log.md
log = {}
---Very fine-grained, step-by-step detail. Shown with LUNA_LOG=trace.
---@param ... any
function log.trace(...) end
---Developer diagnostics. Shown with LUNA_LOG=debug.
---@param ... any
function log.debug(...) end
---Normal high-level progress. Shown with LUNA_LOG=info.
---@param ... any
function log.info(...) end
---Something unexpected but recoverable. Shown by default.
---@param ... any
function log.warn(...) end
---Alias of log.warn.
---@param ... any
function log.warning(...) end
---A failure worth reporting. Shown by default.
---@param ... any
function log.error(...) end

@ -1,31 +0,0 @@
---@meta
---@diagnostic disable: missing-return
-- Type stubs for the Luna additions to the sandboxed `os` table.
--
-- The sandbox trims `os` to its time-related functions — os.time, os.clock,
-- os.date, os.difftime, os.getenv — plus the additions below. The
-- system-access functions (os.execute, os.exit, os.remove, os.rename,
-- os.tmpname, os.setlocale) do NOT exist at runtime; lua-language-server
-- cannot hide individual fields, so it still suggests them — do not use them.
-- Reference: docs/os.md
---Command-line arguments passed to the script: everything after the script
---path on the `luna` command line (or after a literal `--`), including
---arguments given directly to an executable `#!/usr/bin/env luna` script.
---Returns a fresh array each call; empty in the REPL. Arguments are raw byte
---strings (no UTF-8 conversion).
---@return string[] args
function os.args() end
---Current time as a Unix timestamp in seconds, as a float with sub-second
---precision (wall clock — may jump if the system clock is adjusted). Use the
---difference of two readings for interval timing.
---@return number timestamp
function os.microtime() end
---Async sleep: pause for `seconds` (a float, so 0.1 is 100 ms) by suspending
---on the runtime instead of blocking the thread — sibling task.join tasks
---keep running. Does not actually wait inside self-driven coroutines
---(coroutine.wrap/resume); run those through task.join instead.
---@param seconds number non-negative finite
function os.sleep(seconds) end

@ -1,16 +0,0 @@
---@meta
---@diagnostic disable: missing-return
-- Type stub for the sandboxed `require`. The stock `package` library does not
-- exist in Luna (it is disabled in .luarc.json, which also removes the stock
-- `require` definition — this stub replaces it). Reference: docs/require.md
--
-- Names are dot-separated (`require "lib.helper"` → lib/helper.lua or
-- lib/helper/init.lua), resolved against the main script's directory and each
-- entry of $LUNA_INCLUDE_PATHS. Each module runs once; the result is cached.
---Load a module. Returns the module's value and, unlike stock Lua's loader
---data, the resolved file path as the second value.
---@param modname string dot-separated module name
---@return any module
---@return string path the resolved file path
function require(modname) end

@ -1,72 +0,0 @@
---@meta
---@diagnostic disable: missing-return
-- Type stubs for the `sqlite` module. The native core provides sqlite.connect
-- and the connection methods; lua/stdlib/sqlite.lua adds the transaction
-- helpers (typed here too, since the source defines them on a hidden method
-- table). Reference: docs/sqlite.md
--
-- Failures raise Lua errors (bad SQL, unbindable type, parameter mismatch,
-- closed connection) — trap with pcall/utils.try where needed.
sqlite = {}
---Parameters bind positionally (array for `?`) or by name (string keys for
---`:name`/`@name`/`$name`); mixing the two is an error. Bind utils.NULL for
---SQL NULL — a literal nil cannot live in a table.
---@alias sqlite.Params any[]|table<string, any>
---@class sqlite.Connection
local Connection = {}
---Run a statement that changes data (INSERT, UPDATE, DELETE, CREATE, …).
---@param sql string
---@param params sqlite.Params?
---@return integer changed number of rows changed
function Connection:execute(sql, params) end
---Run a SELECT and return all rows, each a table keyed by column name.
---A NULL column is simply absent from its row table.
---@param sql string
---@param params sqlite.Params?
---@return table[] rows
function Connection:query(sql, params) end
---Like query, but returns only the first row, or nil if nothing matched.
---@param sql string
---@param params sqlite.Params?
---@return table? row
function Connection:queryOne(sql, params) end
---Rowid of the most recent successful INSERT on this connection.
---@return integer
function Connection:lastInsertRowid() end
---Rows changed by the most recent INSERT/UPDATE/DELETE.
---@return integer
function Connection:changes() end
---Release the connection eagerly (also happens on garbage collection).
---Any further call on the connection raises.
function Connection:close() end
---Start a transaction (BEGIN).
function Connection:begin() end
---Commit the current transaction (COMMIT).
function Connection:commit() end
---Discard the current transaction (ROLLBACK).
function Connection:rollback() end
---Run fn inside a transaction: commit on success, roll back and re-raise on
---error. fn's return values are passed through.
---@param fn function
---@return any ...
function Connection:transaction(fn) end
---Open a database file (created if missing), or ":memory:" for a private
---in-memory database. A file-backed database runs the fs permission system
---(one combined read/write ask for its directory); ":memory:" never does.
---@param path string
---@return sqlite.Connection
function sqlite.connect(path) end

@ -1,14 +0,0 @@
---@meta
---@diagnostic disable: missing-return
-- Type stubs for the native `task` module. Reference: docs/concurrency.md
task = {}
---Run each function as its own coroutine, drive them concurrently on the
---async runtime, and return each one's first result positionally once all
---have finished. Async stdlib calls (os.sleep, http, sqlite, prompts) inside
---the tasks suspend and overlap — wall-clock time is the longest task, not
---the sum. If a task raises, the first error is re-raised.
---@param ... fun(): any
---@return any ...
function task.join(...) end

@ -1,318 +0,0 @@
---@meta
---@diagnostic disable: missing-return
-- Type stubs for the native `tui` module: interactive prompts, styled output,
-- message blocks and terminal control. Always available as the global `tui`.
-- Reference: docs/tui.md
--
-- Shared prompt behavior: Esc returns nil; Ctrl-C raises "<fn>: interrupted";
-- prompting without a terminal raises "<fn>: not a terminal". Prompts are
-- async — they suspend only the calling coroutine.
tui = {}
----------------------------------------------------------------------
-- Prompts
----------------------------------------------------------------------
---@class tui.ConfirmOpts
---@field help string? hint line shown below the prompt
---@field placeholder string? dim hint shown while the input is empty
---Yes/no question, answered with y/n. `default` is returned on plain Enter;
---with nil the user must type an answer. Esc returns nil (distinct from false).
---@param text string
---@param default boolean?
---@param opts tui.ConfirmOpts?
---@return boolean? answer
function tui.confirm(text, default, opts) end
---@class tui.TextOpts
---@field help string?
---@field placeholder string? dim hint shown while the input is empty
---@field default string? returned when the input is submitted empty (distinct from the pre-filled positional default)
---@field minLen integer? length bound, enforced inline
---@field maxLen integer? length bound, enforced inline
---@field suggestions string[]? autocompletion candidates (substring filter, Tab completes)
---@field pageSize integer? visible suggestion rows
---One-line text input. The positional `default` is pre-filled, editable text.
---@param text string
---@param default string?
---@param opts tui.TextOpts?
---@return string? answer
function tui.promptText(text, default, opts) end
---@class tui.LongTextOpts
---@field help string?
---@field extension string? temp-file extension for editor syntax highlighting (default ".txt")
---@field editor string? editor command instead of $VISUAL/$EDITOR
---Multi-line input via an external editor ($VISUAL/$EDITOR) on a temp file.
---@param text string
---@param default string? text the editor buffer starts with
---@param opts tui.LongTextOpts?
---@return string? answer
function tui.promptLongText(text, default, opts) end
---@class tui.SelectOpts
---@field options string[] the choices (required, non-empty)
---@field help string?
---@field multiple boolean? checkbox multi-select; result becomes string[]
---@field pageSize integer? visible rows (default 7)
---@field vimMode boolean? hjkl navigation
---@field filter boolean? set false to disable type-to-filter
---@field minSelected integer? multiple only, enforced inline
---@field maxSelected integer? multiple only, enforced inline
---@field allSelected boolean? multiple only: start with everything checked
---Pick from a list (filterable). `default` is matched against the options by
---value; a value not among them raises. With `opts.multiple` the default may
---be a string or array of strings and the result is an array.
---@param text string
---@param default string|string[]|nil
---@param opts tui.SelectOpts
---@return string|string[]|nil answer
function tui.promptSelect(text, default, opts) end
---@class tui.SecretOpts
---@field help string?
---@field confirm boolean? ask twice and require both entries to match
---@field display "masked"|"hidden"|"full"? echo mode (default "masked")
---@field toggle boolean? allow Ctrl-R to reveal the input
---@field minLen integer? enforced inline
---Secret input. Deliberately has no default parameter.
---@param text string
---@param opts tui.SecretOpts?
---@return string? answer
function tui.promptSecret(text, opts) end
---@class tui.NumberOpts
---@field help string?
---@field placeholder string?
---@field min number? enforced inline
---@field max number? enforced inline
---Numeric input, re-prompted until it parses. `default` is returned on empty submit.
---@param text string
---@param default number?
---@param opts tui.NumberOpts?
---@return number? answer
function tui.promptNumber(text, default, opts) end
---Whole-number input, re-prompted until it parses. A fractional `default`
---raises at call time.
---@param text string
---@param default integer?
---@param opts tui.NumberOpts?
---@return integer? answer
function tui.promptInt(text, default, opts) end
---@class tui.DateOpts
---@field help string?
---@field min string? earliest selectable date, "YYYY-MM-DD"
---@field max string? latest selectable date, "YYYY-MM-DD"
---@field weekStart string? first day of the week, e.g. "monday" (default Sunday)
---Interactive calendar. Dates cross the API as "YYYY-MM-DD" strings.
---@param text string
---@param default string? initially selected date, "YYYY-MM-DD"
---@param opts tui.DateOpts?
---@return string? date
function tui.promptDate(text, default, opts) end
----------------------------------------------------------------------
-- Styled output
----------------------------------------------------------------------
---Render HTML-like style tags (`<info>`, `<fg=#c0392b;options=bold>`, shorthand
---`<red;on-white;b>`, `<href=URL>`, action tags like `<clear=line>`) into a
---string with ANSI escapes. Degrades to plain text when piped or NO_COLOR is
---set. `</>` closes the innermost style; invalid tags pass through literally.
---@param markup string
---@return string
function tui.styled(markup) end
---Backslash-escape the markup metacharacters (`<`, `>`, `\`) so untrusted
---text — user input, API data — can be embedded in a `tui.styled` format
---string and come out verbatim, without breaking the surrounding markup.
---@param text string
---@return string
function tui.escape(text) end
---Register a custom tag for `tui.styled` and block styles. The spec uses tag
---syntax: attributes ("fg=white;bg=magenta"), shorthand ("i;bold"), or another
---preset name. Re-registering overwrites; presets may shadow built-ins.
---@param name string identifier-like tag name
---@param style string
function tui.addPreset(name, style) end
----------------------------------------------------------------------
-- Message blocks (print directly to stdout, plain-text message)
----------------------------------------------------------------------
---@class tui.BlockOpts
---@field label string? the "[LABEL]" text
---@field style string? tag-body syntax: preset name, spec or shorthand
---@field prefix string? prepended to every line (default " ")
---@field padding boolean? blank styled line above and below
---@param message string|string[]
---@param opts tui.BlockOpts?
function tui.success(message, opts) end
---@param message string|string[]
---@param opts tui.BlockOpts?
function tui.error(message, opts) end
---@param message string|string[]
---@param opts tui.BlockOpts?
function tui.warning(message, opts) end
---@param message string|string[]
---@param opts tui.BlockOpts?
function tui.caution(message, opts) end
---@param message string|string[]
---@param opts tui.BlockOpts?
function tui.info(message, opts) end
---@param message string|string[]
---@param opts tui.BlockOpts?
function tui.note(message, opts) end
---@param message string|string[]
---@param opts tui.BlockOpts?
function tui.comment(message, opts) end
---Generic block: everything comes from opts.
---@param message string|string[]
---@param opts tui.BlockOpts?
function tui.block(message, opts) end
---@class tui.OutlineOpts
---@field title string? shown in the top border
---@field style string? border color, tag-body syntax
---@field padding boolean? blank line above/below the content inside the box (default true)
---@param message string|string[]
---@param opts tui.OutlineOpts?
function tui.outlineSuccess(message, opts) end
---@param message string|string[]
---@param opts tui.OutlineOpts?
function tui.outlineError(message, opts) end
---@param message string|string[]
---@param opts tui.OutlineOpts?
function tui.outlineWarning(message, opts) end
---@param message string|string[]
---@param opts tui.OutlineOpts?
function tui.outlineNote(message, opts) end
---@param message string|string[]
---@param opts tui.OutlineOpts?
function tui.outlineInfo(message, opts) end
---@param message string|string[]
---@param opts tui.OutlineOpts?
function tui.outlineCaution(message, opts) end
---Generic outline block.
---@param message string|string[]
---@param opts tui.OutlineOpts?
function tui.outlineBlock(message, opts) end
----------------------------------------------------------------------
-- Low-level output
----------------------------------------------------------------------
---Write arguments to stdout with no newline and no separator (each converted
---with tostring), then flush.
---@param ... any
function tui.raw(...) end
---@class tui.ScreenInfo
---@field width integer? terminal width in cells; nil when there is no terminal
---@field height integer? terminal height in cells; nil when there is no terminal
---@field tty boolean whether stdout is a terminal
---@field colors boolean whether styled output will actually emit colors
---Facts about the terminal, for scripts that render their own UI.
---@return tui.ScreenInfo
function tui.screenInfo() end
----------------------------------------------------------------------
-- Terminal control (no-ops without a terminal; bad arguments always raise;
-- alternate screen / cursor / scroll region / title restored on exit)
----------------------------------------------------------------------
---Absolute cursor move, 1-based. nil for one axis keeps it.
---@param row integer?
---@param col integer?
function tui.moveTo(row, col) end
---Relative cursor move: positive dx right, positive dy down; nil counts as 0.
---@param dx integer?
---@param dy integer?
function tui.move(dx, dy) end
---Save the cursor position (one slot).
function tui.saveCursor() end
---Restore the saved cursor position.
function tui.restoreCursor() end
---Ask the terminal where the cursor is (1-based). Returns nil when there is
---no terminal or it does not answer. Async, serialized against prompts.
---@return integer? row
---@return integer? col
function tui.cursorPos() end
---Hide (false) or show (true or no argument) the cursor.
---@param visible boolean?
function tui.showCursor(visible) end
---@class tui.CursorStyleOpts
---@field blink boolean?
---Set the cursor shape; no arguments reset to the terminal default.
---@param style "block"|"underline"|"bar"|nil
---@param opts tui.CursorStyleOpts?
function tui.cursorStyle(style, opts) end
---Erase the current line. mode: 0/nil = whole (cursor to column 1),
----1 = up to the cursor, 1 = cursor to end (cursor stays).
---@param mode integer?
function tui.clearLine(mode) end
---Erase the screen. mode: 0/nil = whole (cursor home), -1 = above the cursor,
---1 = below the cursor (cursor stays).
---@param mode integer?
function tui.clearScreen(mode) end
---Switch to (true) or back from (false) the alternate screen buffer.
---@param on boolean
function tui.altScreen(on) end
---Restrict scrolling to rows top..bottom (1-based, inclusive); moves the
---cursor home. No arguments reset to the full screen.
---@param top integer?
---@param bottom integer?
function tui.scrollRegion(top, bottom) end
---Scroll the scroll region: positive n up, negative down. Cursor stays.
---@param n integer
function tui.scroll(n) end
---Set the terminal window title (restored on exit). Control characters raise.
---@param text string
function tui.setTitle(text) end
---Copy text into the system clipboard via the terminal (OSC 52). Write-only.
---@param text string
function tui.setClipboard(text) end
---Soft-reset the terminal (DECSTR) without clearing the screen.
function tui.reset() end

@ -1,40 +0,0 @@
---@meta
---@diagnostic disable: missing-return
-- Type stubs for the NATIVE half of the `utils` module: the JSON codec, the
-- NULL sentinel and the pretty-printer. The Lua-implemented half (utils.try,
-- utils.tryn) lives in lua/stdlib/utils.lua and needs no stub.
-- Reference: docs/utils.md
utils = {}
---Sentinel standing for an explicit null in tables (a Lua nil would mean "key
---absent" — assigning nil deletes the key). Produced by fromJSON for JSON
---null; serialized back to null by toJSON and the http helpers; binds SQL
---NULL in sqlite. Compare with utils.isNull.
---@type userdata
utils.NULL = nil
---Serialize a Lua value to a JSON string. A table with keys exactly 1..#t
---becomes an array, any other table an object; utils.NULL becomes null.
---Raises on NaN/infinity, values with no JSON form, or nesting beyond 64.
---@param value any
---@param pretty boolean? indented multi-line output (default compact)
---@return string
function utils.toJSON(value, pretty) end
---Parse a JSON string into a Lua value. JSON null decodes to utils.NULL (not
---nil), so nulls inside arrays do not create gaps. Invalid JSON raises.
---@param str string
---@return any
function utils.fromJSON(str) end
---True if value is the utils.NULL sentinel. isNull(nil) is false.
---@param value any
---@return boolean
function utils.isNull(value) end
---Render any Lua value as a readable string, for logging and inspection.
---Cycles print as <circular>; nesting is capped at 20 levels.
---@param value any
---@return string
function utils.dump(value) end

@ -0,0 +1,53 @@
-- Smoke-test for the stdlib extensions.
print("=== math ===")
print("round(2.5) =", math.round(2.5)) -- 3
print("round(2.567,2) =", math.round(2.567, 2)) -- 2.57
print("clamp(10,0,5) =", math.clamp(10, 0, 5)) -- 5
print("isFinite(1/0) =", math.isFinite(1/0)) -- false
print("isFinite(3.14) =", math.isFinite(3.14)) -- true
print("sign(-7) =", math.sign(-7)) -- -1
print("scale(5,0,10,0,100) =", math.scale(5, 0, 10, 0, 100)) -- 50.0
print("\n=== table ===")
local t = {3, 1, 4, 1, 5}
print("sum =", table.sum(t)) -- 14
print("min =", table.min(t)) -- 1
print("max =", table.max(t)) -- 5
print("mean =", table.mean(t)) -- 2.8
local evens = table.ifilter(t, function(v) return v % 2 == 0 end)
print("evens =", table.concat(evens, ",")) -- 4
local doubled = table.map({1,2,3}, function(v) return v * 2 end)
print("doubled=", table.concat(doubled, ",")) -- 2,4,6
print("deepCopy ok:", table.deepCopy({x={y=1}}).x.y == 1)
print("merged =", table.concat(table.merge({1,2},{3,4}), ",")) -- 1,2,3,4
print("\n=== utils ===")
print("dump(nil) =", utils.dump(nil))
print("dump({1,2}) =", utils.dump({1, 2}))
print("dump({x=1}) =", utils.dump({x = 1}))
print("isNull(NULL) =", utils.isNull(utils.NULL))
print("isNull(nil) =", utils.isNull(nil))
local json = utils.toJSON({name="Alice", age=30, tags={"a","b"}})
print("toJSON =", json)
local parsed = utils.fromJSON(json)
print("fromJSON name=", parsed.name, "age=", parsed.age)
local v, err = utils.try(function() return 42 end)
print("try ok: v=", v, "err=", err)
local v2, err2 = utils.try(function() error("oops") end)
print("try err: v=", v2, "err ~= nil:", err2 ~= nil)
print("\n=== os ===")
local t1 = os.microtime()
os.sleep(0.01)
local t2 = os.microtime()
print("microtime ok:", t2 > t1)
print("clock >= 0:", os.clock() >= 0)
print("\n=== log (set RUST_LOG=info to see output on stderr) ===")
log.info("hello from log.info")
log.warn("hello from log.warn")
print("\nAll checks passed.")

@ -2,10 +2,8 @@
local function assert_eq(actual, expected, msg)
if actual ~= expected then
error(
string.format("%s: expected %s, got %s", msg or "assertion failed", tostring(expected), tostring(actual)),
2
)
error(string.format("%s: expected %s, got %s",
msg or "assertion failed", tostring(expected), tostring(actual)), 2)
end
end
@ -27,7 +25,7 @@ con:execute([[
-- Positional params, every supported type.
local changed = con:execute(
"INSERT INTO people (name, age, score, vip, notes) VALUES (?, ?, ?, ?, ?)",
{ "Alice", 30, 9.5, true, "first" }
{"Alice", 30, 9.5, true, "first"}
)
assert_eq(changed, 1, "execute returns rows changed")
assert_eq(con:changes(), 1, "changes() after insert")
@ -37,7 +35,7 @@ assert_eq(first_id, 1, "lastInsertRowid")
-- Named params (mix of :name in SQL).
con:execute(
"INSERT INTO people (name, age, score, vip, notes) VALUES (:name, :age, :score, :vip, :notes)",
{ name = "Bob", age = 42, score = 7.25, vip = false, notes = utils.NULL }
{name = "Bob", age = 42, score = 7.25, vip = false, notes = utils.NULL}
)
assert_eq(con:lastInsertRowid(), 2, "lastInsertRowid after second insert")
@ -53,33 +51,33 @@ assert_eq(rows[1].notes, "first", "row 1 notes (text round-trip)")
assert_eq(rows[2].notes, nil, "row 2 notes is nil (NULL round-trip)")
print("=== queryOne ===")
local bob = con:queryOne("SELECT * FROM people WHERE name = ?", { "Bob" })
local bob = con:queryOne("SELECT * FROM people WHERE name = ?", {"Bob"})
assert(bob ~= nil, "queryOne found Bob")
assert_eq(bob.age, 42, "queryOne Bob age")
local missing = con:queryOne("SELECT * FROM people WHERE name = ?", { "Nobody" })
local missing = con:queryOne("SELECT * FROM people WHERE name = ?", {"Nobody"})
assert_eq(missing, nil, "queryOne returns nil for no match")
print("=== UPDATE / DELETE ===")
local n = con:execute("UPDATE people SET age = age + 1 WHERE name = :who", { who = "Alice" })
local n = con:execute("UPDATE people SET age = age + 1 WHERE name = :who", {who = "Alice"})
assert_eq(n, 1, "update affected 1 row")
assert_eq(con:queryOne("SELECT age FROM people WHERE name = ?", { "Alice" }).age, 31, "age incremented")
assert_eq(con:queryOne("SELECT age FROM people WHERE name = ?", {"Alice"}).age, 31, "age incremented")
n = con:execute("DELETE FROM people WHERE name = ?", { "Bob" })
n = con:execute("DELETE FROM people WHERE name = ?", {"Bob"})
assert_eq(n, 1, "delete affected 1 row")
assert_eq(#con:query("SELECT * FROM people"), 1, "one row left after delete")
print("=== transaction(fn) — commit ===")
con:transaction(function()
con:execute("INSERT INTO people (name, age) VALUES (?, ?)", { "Carol", 25 })
con:execute("INSERT INTO people (name, age) VALUES (?, ?)", { "Dave", 28 })
con:execute("INSERT INTO people (name, age) VALUES (?, ?)", {"Carol", 25})
con:execute("INSERT INTO people (name, age) VALUES (?, ?)", {"Dave", 28})
end)
assert_eq(#con:query("SELECT * FROM people"), 3, "two rows committed")
print("=== transaction(fn) — rollback on error ===")
local ok, err = pcall(function()
con:transaction(function()
con:execute("INSERT INTO people (name, age) VALUES (?, ?)", { "Eve", 99 })
con:execute("INSERT INTO people (name, age) VALUES (?, ?)", {"Eve", 99})
error("boom")
end)
end)
@ -89,27 +87,25 @@ assert_eq(#con:query("SELECT * FROM people"), 3, "failed transaction rolled back
print("=== manual begin / commit ===")
con:begin()
con:execute("INSERT INTO people (name, age) VALUES (?, ?)", { "Frank", 50 })
con:execute("INSERT INTO people (name, age) VALUES (?, ?)", {"Frank", 50})
con:commit()
assert_eq(#con:query("SELECT * FROM people"), 4, "manual commit persisted")
print("=== manual begin / rollback ===")
con:begin()
con:execute("INSERT INTO people (name, age) VALUES (?, ?)", { "Grace", 60 })
con:execute("INSERT INTO people (name, age) VALUES (?, ?)", {"Grace", 60})
con:rollback()
assert_eq(#con:query("SELECT * FROM people"), 4, "manual rollback discarded")
print("=== mixed param table is rejected ===")
local mixed_ok = pcall(function()
con:query("SELECT 1 WHERE 1 = ?", { 1, key = "x" })
con:query("SELECT 1 WHERE 1 = ?", {1, key = "x"})
end)
assert_eq(mixed_ok, false, "mixed positional/named params error")
print("=== close ===")
con:close()
local closed_ok = pcall(function()
con:query("SELECT 1")
end)
local closed_ok = pcall(function() con:query("SELECT 1") end)
assert_eq(closed_ok, false, "use after close errors")
print("\nAll sqlite checks passed.")

@ -1,313 +0,0 @@
//! Tests for the async parts of the stdlib: os.sleep, task.join and sqlite.
//! These have no flowbox-rt counterpart and need a tokio runtime, so they live
//! apart from the ported sync tests.
use std::time::{Duration, Instant};
use super::lua;
#[tokio::test]
async fn test_os_sleep_actually_sleeps() {
let lua = lua();
let start = Instant::now();
lua.load(r#"os.sleep(0.1)"#).exec_async().await.unwrap();
let elapsed = start.elapsed();
assert!(elapsed >= Duration::from_millis(95), "slept only {elapsed:?}");
assert!(elapsed < Duration::from_millis(500), "slept too long: {elapsed:?}");
}
#[tokio::test]
async fn test_os_sleep_invalid_duration() {
let lua = lua();
// Negative, NaN and infinite durations must raise a Lua error, not panic the host
for src in ["os.sleep(-1)", "os.sleep(0/0)", "os.sleep(math.huge)"] {
let err = lua.load(src).exec_async().await.unwrap_err();
assert!(err.to_string().contains("os.sleep"), "{src}: {err}");
}
}
#[tokio::test]
async fn test_os_sleep_rejects_huge_finite_duration() {
let lua = lua();
// A huge-but-finite duration must be rejected promptly, not parked forever
// (which would read as the runtime hanging). The test itself would hang if
// this regressed, so tokio's test timeout / the assertion guards it.
let err = lua.load(r#"os.sleep(1e18)"#).exec_async().await.unwrap_err();
assert!(err.to_string().contains("os.sleep"), "{err}");
// A duration within the cap is accepted (0 sleeps instantly).
lua.load(r#"os.sleep(0)"#).exec_async().await.unwrap();
}
#[tokio::test]
async fn test_task_join_returns_results_positionally() {
let lua = lua();
let (a, b, c): (i64, String, bool) = lua
.load(
r#"
return task.join(
function() return 1 end,
function() os.sleep(0.01) return "two" end,
function() return true end
)
"#,
)
.eval_async()
.await
.unwrap();
assert_eq!(a, 1);
assert_eq!(b, "two");
assert!(c);
}
#[tokio::test]
async fn test_task_join_runs_concurrently() {
let lua = lua();
let start = Instant::now();
lua.load(
r#"
task.join(
function() os.sleep(0.15) end,
function() os.sleep(0.1) end,
function() os.sleep(0.05) end
)
"#,
)
.exec_async()
.await
.unwrap();
let elapsed = start.elapsed();
// Sequential execution would take ~0.3s; overlapped, the slowest task dominates.
assert!(elapsed >= Duration::from_millis(140), "finished too fast: {elapsed:?}");
assert!(elapsed < Duration::from_millis(280), "tasks did not overlap: {elapsed:?}");
}
#[tokio::test]
async fn test_task_join_rejects_non_function_argument() {
let lua = lua();
// A non-function argument gives a prefixed, positioned error, not mlua's
// bare "error converting Lua integer to function".
let err = lua
.load(r#"task.join(function() end, 42)"#)
.exec_async()
.await
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("task.join: argument #2 must be a function"), "{msg}");
assert!(msg.contains("integer"), "{msg}");
}
#[tokio::test]
async fn test_os_sleep_rejects_non_number() {
let lua = lua();
let err = lua.load(r#"os.sleep("soon")"#).exec_async().await.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("os.sleep: duration must be a number"), "{msg}");
}
#[tokio::test]
async fn test_task_join_propagates_errors() {
let lua = lua();
let err = lua
.load(r#"task.join(function() error("boom") end, function() return 1 end)"#)
.exec_async()
.await
.unwrap_err();
assert!(err.to_string().contains("boom"));
}
#[tokio::test]
async fn test_sqlite_in_memory_roundtrip() {
let lua = lua();
let (name, id, count): (String, i64, i64) = lua
.load(
r#"
local db = sqlite.connect(":memory:")
db:execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
db:execute("INSERT INTO t (name) VALUES (?)", {"alice"})
db:execute("INSERT INTO t (name) VALUES (?)", {"bob"})
local id = db:lastInsertRowid()
local row = db:queryOne("SELECT name FROM t WHERE id = ?", {1})
local rows = db:query("SELECT * FROM t")
db:close()
return row.name, id, #rows
"#,
)
.eval_async()
.await
.unwrap();
assert_eq!(name, "alice");
assert_eq!(id, 2);
assert_eq!(count, 2);
}
#[tokio::test]
async fn test_sqlite_binds_non_utf8_as_blob() {
let lua = lua();
// A non-UTF-8 / NUL-containing Lua string must bind and round-trip intact
// (as a BLOB) rather than failing the UTF-8 conversion.
let ok: bool = lua
.load(
r#"
local db = sqlite.connect(":memory:")
db:execute("CREATE TABLE t (id INTEGER PRIMARY KEY, data)")
local blob = "\255\0\254bin"
db:execute("INSERT INTO t (data) VALUES (?)", {blob})
local got = db:queryOne("SELECT data FROM t WHERE id = 1").data
return got == blob
"#,
)
.eval_async()
.await
.unwrap();
assert!(ok);
}
#[tokio::test]
async fn test_sqlite_rejects_empty_and_multi_statements() {
let lua = lua();
let err = lua
.load(r#"local db = sqlite.connect(":memory:"); return db:execute(" ")"#)
.eval_async::<mlua::Value>()
.await
.unwrap_err();
assert!(err.to_string().contains("no SQL statement"), "{err}");
// A comment-only statement has no runnable SQL either.
let err = lua
.load(r#"local db = sqlite.connect(":memory:"); return db:query("-- only a comment")"#)
.eval_async::<mlua::Value>()
.await
.unwrap_err();
assert!(err.to_string().contains("no SQL statement"), "{err}");
let err = lua
.load(
r#"
local db = sqlite.connect(":memory:")
db:execute("CREATE TABLE t (x)")
return db:execute("INSERT INTO t VALUES (1); INSERT INTO t VALUES (2)")
"#,
)
.eval_async::<mlua::Value>()
.await
.unwrap_err();
assert!(err.to_string().contains("single SQL statement"), "{err}");
}
#[tokio::test]
async fn test_sqlite_execute_batch_runs_all_statements() {
let lua = lua();
let count: i64 = lua
.load(
r#"
local db = sqlite.connect(":memory:")
db:executeBatch([[
CREATE TABLE t (x);
INSERT INTO t VALUES (1);
INSERT INTO t VALUES (2);
INSERT INTO t VALUES (3);
]])
return db:queryOne("SELECT COUNT(*) AS n FROM t").n
"#,
)
.eval_async()
.await
.unwrap();
assert_eq!(count, 3);
}
#[tokio::test]
async fn test_sqlite_transaction_passes_through_return_values() {
let lua = lua();
let (a, b): (i64, String) = lua
.load(
r#"
local db = sqlite.connect(":memory:")
db:execute("CREATE TABLE t (x)")
return db:transaction(function() return 7, "eight" end)
"#,
)
.eval_async()
.await
.unwrap();
assert_eq!(a, 7);
assert_eq!(b, "eight");
}
#[tokio::test]
async fn test_sqlite_transaction_does_not_mask_original_error() {
let lua = lua();
// If the body fails AND rollback then also fails (here: the connection is
// closed inside the body), the caller must still see the body's error.
let err = lua
.load(
r#"
local db = sqlite.connect(":memory:")
db:execute("CREATE TABLE t (x)")
db:transaction(function()
db:close()
error("ORIGINAL ERROR")
end)
"#,
)
.exec_async()
.await
.unwrap_err();
assert!(err.to_string().contains("ORIGINAL ERROR"), "{err}");
}
#[tokio::test]
async fn test_sqlite_transaction_commit_and_rollback() {
let lua = lua();
let (after_commit, after_rollback): (i64, i64) = lua
.load(
r#"
local db = sqlite.connect(":memory:")
db:execute("CREATE TABLE t (x)")
db:transaction(function()
db:execute("INSERT INTO t VALUES (1)")
end)
local committed = db:queryOne("SELECT COUNT(*) AS n FROM t").n
local ok, err = pcall(function()
db:transaction(function()
db:execute("INSERT INTO t VALUES (2)")
error("nope")
end)
end)
assert(not ok)
assert(tostring(err):find("nope"))
local rolled_back = db:queryOne("SELECT COUNT(*) AS n FROM t").n
return committed, rolled_back
"#,
)
.eval_async()
.await
.unwrap();
assert_eq!(after_commit, 1);
// The failed transaction's insert must have been rolled back.
assert_eq!(after_rollback, 1);
}

@ -1,226 +0,0 @@
//! Core environment tests: basic Lua evaluation, sandbox verification, and
//! checks that the stdlib extends rather than replaces the standard modules.
//!
//! Ported from flowbox-rt's fb_lua core_tests, with sandbox-verification
//! tests added for this project's sandbox (see src/sandbox.rs).
use super::assert_eq_f64;
/// Smoke test for the environment setup
#[test]
fn test_call_lua_function() {
let lua = super::lua();
let chunk: mlua::Function = lua
.load(
r#"
function(a, b)
return a + b
end
"#,
)
.eval()
.unwrap();
let res: i32 = chunk.call((10, 20)).unwrap();
assert_eq!(res, 30);
}
/// dofile, loadfile and load (raw bytecode loading) are removed from the sandbox.
#[test]
fn test_sandbox_chunk_loaders_removed() {
let lua = super::lua();
for name in ["dofile", "loadfile", "load"] {
let is_nil: bool = lua.load(format!("return {name} == nil")).eval().unwrap();
assert!(is_nil, "global `{name}` should be nil in the sandbox");
}
}
/// string.dump (bytecode producer, the counterpart of load) is removed.
#[test]
fn test_sandbox_string_dump_removed() {
let lua = super::lua();
let is_nil: bool = lua.load(r#"return string.dump == nil"#).eval().unwrap();
assert!(is_nil, "string.dump should be nil in the sandbox");
}
/// io, package and debug libraries are not loaded at all.
#[test]
fn test_sandbox_io_package_debug_not_loaded() {
let lua = super::lua();
for name in ["io", "package", "debug"] {
let is_nil: bool = lua.load(format!("return {name} == nil")).eval().unwrap();
assert!(is_nil, "global `{name}` should be nil in the sandbox");
}
}
/// collectgarbage only allows the "count" option; everything else raises
/// an error mentioning the sandbox.
#[test]
fn test_sandbox_collectgarbage_count_only() {
let lua = super::lua();
let count: f64 = lua.load(r#"return collectgarbage("count")"#).eval().unwrap();
assert!(count > 0.0, "collectgarbage(\"count\") should report heap KB, got {count}");
let err = lua.load(r#"return collectgarbage()"#).exec().unwrap_err();
assert!(err.to_string().contains("sandboxed"), "unexpected error: {err}");
let err = lua.load(r#"return collectgarbage("collect")"#).exec().unwrap_err();
assert!(err.to_string().contains("sandboxed"), "unexpected error: {err}");
}
/// The os table only keeps the safe time functions plus our extensions.
#[test]
fn test_sandbox_os_dangerous_functions_removed() {
let lua = super::lua();
let os_table = lua.globals().get::<mlua::Table>("os").unwrap();
for name in ["execute", "exit", "getenv", "remove", "rename", "setlocale", "tmpname"] {
assert!(
!os_table.contains_key(name).unwrap(),
"os.{name} should be removed from the sandbox"
);
}
for name in ["date", "difftime", "time", "clock", "microtime", "sleep"] {
assert!(
os_table.contains_key(name).unwrap(),
"os.{name} should be present in the sandbox"
);
}
}
/// Verify that the math table was extended with custom functions,
/// not replaced - standard Lua math functions must still exist.
#[test]
fn test_math_table_extended_not_replaced() {
let lua = super::lua();
// Test standard math functions still work
let result: f64 = lua.load(r#"return math.floor(3.7)"#).eval().unwrap();
assert_eq_f64!(result, 3.0);
let result: f64 = lua.load(r#"return math.ceil(3.2)"#).eval().unwrap();
assert_eq_f64!(result, 4.0);
let result: f64 = lua.load(r#"return math.abs(-5)"#).eval().unwrap();
assert_eq_f64!(result, 5.0);
let result: f64 = lua.load(r#"return math.sqrt(16)"#).eval().unwrap();
assert_eq_f64!(result, 4.0);
let result: f64 = lua.load(r#"return math.sin(0)"#).eval().unwrap();
assert_eq_f64!(result, 0.0);
let result: f64 = lua.load(r#"return math.cos(0)"#).eval().unwrap();
assert_eq_f64!(result, 1.0);
let result: f64 = lua.load(r#"return math.max(1, 5, 3)"#).eval().unwrap();
assert_eq_f64!(result, 5.0);
let result: f64 = lua.load(r#"return math.min(1, 5, 3)"#).eval().unwrap();
assert_eq_f64!(result, 1.0);
// Verify math.pi constant exists
let result: f64 = lua.load(r#"return math.pi"#).eval().unwrap();
assert!(result > 3.14 && result < 3.15);
// Verify our custom functions are also present
let result: i64 = lua.load(r#"return math.round(3.7)"#).eval().unwrap();
assert_eq!(result, 4);
let result: f64 = lua.load(r#"return math.round(3.1415, 2)"#).eval().unwrap();
assert_eq_f64!(result, 3.14);
let result: i64 = lua.load(r#"return math.clamp(50, 0, 100)"#).eval().unwrap();
assert_eq!(result, 50);
}
/// Verify that the table module was extended with custom functions,
/// not replaced - standard Lua table functions must still exist.
#[test]
fn test_table_module_extended_not_replaced() {
let lua = super::lua();
// Test standard table functions still work
// table.insert
let result: String = lua
.load(
r#"
local t = {1, 2, 3}
table.insert(t, 4)
return utils.toJSON(t)
"#,
)
.eval()
.unwrap();
assert_eq!(result, "[1,2,3,4]");
// table.remove
let result: String = lua
.load(
r#"
local t = {1, 2, 3, 4}
table.remove(t, 2)
return utils.toJSON(t)
"#,
)
.eval()
.unwrap();
assert_eq!(result, "[1,3,4]");
// table.concat
let result: String = lua.load(r#"return table.concat({"a", "b", "c"}, "-")"#).eval().unwrap();
assert_eq!(result, "a-b-c");
// table.sort
let result: String = lua
.load(
r#"
local t = {3, 1, 4, 1, 5, 9}
table.sort(t)
return utils.toJSON(t)
"#,
)
.eval()
.unwrap();
assert_eq!(result, "[1,1,3,4,5,9]");
// table.unpack
let result: i64 = lua
.load(
r#"
local t = {10, 20, 30}
local a, b, c = table.unpack(t)
return a + b + c
"#,
)
.eval()
.unwrap();
assert_eq!(result, 60);
// Verify our custom functions are also present
let _: () = lua
.load(
r#"
local ro = table.readonly({x = 1})
assert(ro.x == 1)
"#,
)
.exec()
.unwrap();
let result: String = lua
.load(
r#"
local merged = table.merge({a = 1}, {b = 2})
return utils.toJSON(merged)
"#,
)
.eval()
.unwrap();
assert!(result.contains("\"a\":1") && result.contains("\"b\":2"));
}

@ -1,436 +0,0 @@
//! Tests for the `fs` module and its permission system, exercised through
//! Lua with policies injected via app data (the same channel `main` uses).
//!
//! No test here can ever reach a real terminal prompt or the real
//! access.json: in test builds the prompt path is compiled out (scripted
//! answers only) and the default policy carries no script identity.
use std::sync::Arc;
use super::lua;
use crate::stdlib::perm::{store, Choice, Override, Policy};
/// The sandboxed state with an allow-everything policy.
fn lua_allow_all() -> mlua::Lua {
let lua = lua();
lua.set_app_data(Arc::new(Policy::allow_all()));
lua
}
/// A policy with only the filesystem statically denied by `flag` (what
/// `--no-fs` builds); origins stay interactive.
fn deny_fs(flag: &'static str) -> Policy {
Policy::new(Some(Override::Deny { flag }), None, None, Default::default(), None)
}
fn lua_with(policy: Policy) -> mlua::Lua {
let lua = lua();
lua.set_app_data(Arc::new(policy));
lua
}
async fn lua_err(lua: &mlua::Lua, src: &str) -> String {
lua.load(src).exec_async().await.unwrap_err().to_string()
}
#[tokio::test]
async fn write_read_round_trip_and_truncation() {
let lua = lua_allow_all();
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("data.bin");
// Non-UTF-8 bytes must survive; a rewrite must truncate, not append.
let got: mlua::String = lua
.load(format!(
r#"
fs.writeAll('{p}', "long original content \255\254\0tail")
fs.writeAll('{p}', "short\255")
return fs.readAll('{p}')
"#,
p = file.display()
))
.eval_async()
.await
.unwrap();
assert_eq!(&*got.as_bytes(), b"short\xff");
}
#[tokio::test]
async fn write_all_creates_missing_file_but_not_missing_parent() {
let lua = lua_allow_all();
let dir = tempfile::tempdir().unwrap();
let fresh = dir.path().join("new.txt");
lua.load(format!("fs.writeAll('{}', 'x')", fresh.display()))
.exec_async()
.await
.unwrap();
assert_eq!(std::fs::read_to_string(&fresh).unwrap(), "x");
let orphan = dir.path().join("no/such/dir/f.txt");
let err = lua_err(&lua, &format!("fs.writeAll('{}', 'x')", orphan.display())).await;
assert!(err.contains("fs.writeAll") && err.contains("parent directory"), "{err}");
}
#[tokio::test]
async fn type_mismatches_error() {
let lua = lua_allow_all();
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("f.txt");
std::fs::write(&file, "x").unwrap();
let err = lua_err(&lua, &format!("fs.readAll('{}')", dir.path().display())).await;
assert!(err.contains("fs.readAll") && err.contains("not a regular file"), "{err}");
let err = lua_err(&lua, &format!("fs.writeAll('{}', 'x')", dir.path().display())).await;
assert!(err.contains("fs.writeAll") && err.contains("is a directory"), "{err}");
let err = lua_err(&lua, &format!("fs.list('{}')", file.display())).await;
assert!(err.contains("fs.list") && err.contains("not a directory"), "{err}");
let err = lua_err(&lua, "fs.readAll('/no/such/file/anywhere')").await;
assert!(err.contains("fs.readAll"), "{err}");
}
#[tokio::test]
async fn list_returns_sorted_names() {
let lua = lua_allow_all();
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("b.txt"), "").unwrap();
std::fs::write(dir.path().join("a.txt"), "").unwrap();
std::fs::create_dir(dir.path().join("sub")).unwrap();
let joined: String = lua
.load(format!(
"return table.concat(fs.list('{}'), ',')",
dir.path().display()
))
.eval_async()
.await
.unwrap();
assert_eq!(joined, "a.txt,b.txt,sub");
}
#[tokio::test]
async fn predicates_check_existence_type_and_access() {
let lua = lua_allow_all();
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("f.txt");
std::fs::write(&file, "x").unwrap();
let (dir_is_dir, dir_is_file, file_is_dir, file_is_file, ghost_dir, ghost_file): (
bool,
bool,
bool,
bool,
bool,
bool,
) = lua
.load(format!(
r#"
return fs.isDir('{d}'), fs.isFile('{d}'),
fs.isDir('{f}'), fs.isFile('{f}'),
fs.isDir('/no/such/path'), fs.isFile('/no/such/path')
"#,
d = dir.path().display(),
f = file.display()
))
.eval_async()
.await
.unwrap();
assert!(dir_is_dir && !dir_is_file && !file_is_dir && file_is_file);
assert!(!ghost_dir && !ghost_file);
}
#[tokio::test]
async fn access_validates_arguments() {
let lua = lua_allow_all();
let dir = tempfile::tempdir().unwrap();
let ok: bool = lua
.load(format!("return fs.access('{}', 'r')", dir.path().display()))
.eval_async()
.await
.unwrap();
assert!(ok);
let err = lua_err(&lua, &format!("fs.access('{}', 'rw')", dir.path().display())).await;
assert!(err.contains("fs.access") && err.contains("mode"), "{err}");
let err = lua_err(&lua, "fs.access('/no/such/dir', 'r')").await;
assert!(err.contains("fs.access"), "{err}");
}
#[tokio::test]
async fn get_work_dir_returns_the_cwd_ungated() {
// Works even under the strictest policy: it reveals process state, not
// filesystem content.
let lua = lua_with(Policy::deny_all("--sandbox"));
let cwd: String = lua.load("return fs.getWorkDir()").eval_async().await.unwrap();
assert_eq!(std::path::PathBuf::from(&cwd), std::env::current_dir().unwrap());
assert!(std::path::Path::new(&cwd).is_absolute());
}
#[tokio::test]
async fn get_script_dir_reports_planted_dir_ungated_and_nil_without_one() {
// No ScriptDir in app data (REPL semantics): nil.
let lua = lua_with(Policy::deny_all("--sandbox"));
let noscript: Option<String> =
lua.load("return fs.getScriptDir()").eval_async().await.unwrap();
assert_eq!(noscript, None);
// With the slot planted (as main does for a script), it comes back
// verbatim even under the strictest policy.
lua.set_app_data(crate::stdlib::fs::ScriptDir("/opt/tool".into()));
let dir: String = lua.load("return fs.getScriptDir()").eval_async().await.unwrap();
assert_eq!(dir, "/opt/tool");
}
#[tokio::test]
async fn deny_all_blocks_everything_without_prompting() {
let lua = lua_with(deny_fs("--no-fs"));
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("f.txt");
std::fs::write(&file, "secret").unwrap();
for op in [
format!("fs.readAll('{}')", file.display()),
format!("fs.writeAll('{}', 'x')", file.display()),
format!("fs.list('{}')", dir.path().display()),
] {
let err = lua_err(&lua, &op).await;
assert!(err.contains("disabled by --no-fs"), "{op}: {err}");
}
let (acc, is_dir, is_file): (bool, bool, bool) = lua
.load(format!(
"return fs.access('{d}', 'r'), fs.isDir('{d}'), fs.isFile('{f}')",
d = dir.path().display(),
f = file.display()
))
.eval_async()
.await
.unwrap();
assert!(!acc && !is_dir && !is_file);
// the file was not touched
assert_eq!(std::fs::read_to_string(&file).unwrap(), "secret");
}
#[tokio::test]
async fn default_policy_denies_unknown_without_persisting() {
// No policy injected: the fs::install default (REPL semantics, no
// scripted answers = nobody to ask) must deny and record nothing.
let lua = lua();
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("f.txt");
std::fs::write(&file, "x").unwrap();
let err = lua_err(&lua, &format!("fs.readAll('{}')", file.display())).await;
assert!(err.contains("fs.readAll") && err.contains("denied"), "{err}");
let ok: bool = lua
.load(format!("return fs.access('{}', 'r')", dir.path().display()))
.eval_async()
.await
.unwrap();
assert!(!ok);
}
#[tokio::test]
async fn interactive_allow_once_grants_operations() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("f.txt");
std::fs::write(&file, "content").unwrap();
// One AllowOnce answer covers the whole run for that directory: the
// second read and the fs.access probe consume no further answers.
let lua = lua_with(Policy::interactive_scripted(
None,
Default::default(),
None,
[Choice::AllowOnce],
));
let (a, b, acc): (String, String, bool) = lua
.load(format!(
r#"return fs.readAll('{f}'), fs.readAll('{f}'), fs.access('{d}', 'r')"#,
f = file.display(),
d = dir.path().display()
))
.eval_async()
.await
.unwrap();
assert_eq!((a.as_str(), b.as_str(), acc), ("content", "content", true));
}
#[tokio::test]
async fn interactive_deny_answer_blocks_and_read_grant_does_not_leak_to_write() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("f.txt");
std::fs::write(&file, "content").unwrap();
// First answer grants the read; the write is a separate (write-kind)
// question answered with DenyOnce.
let lua = lua_with(Policy::interactive_scripted(
None,
Default::default(),
None,
[Choice::AllowOnce, Choice::DenyOnce],
));
let got: String = lua
.load(format!("return fs.readAll('{}')", file.display()))
.eval_async()
.await
.unwrap();
assert_eq!(got, "content");
let err = lua_err(&lua, &format!("fs.writeAll('{}', 'nope')", file.display())).await;
assert!(err.contains("fs.writeAll") && err.contains("denied"), "{err}");
assert_eq!(std::fs::read_to_string(&file).unwrap(), "content");
}
#[tokio::test]
async fn fs_access_allow_always_persists_and_is_honored_next_run() {
let data_dir = tempfile::tempdir().unwrap();
let cfg = tempfile::tempdir().unwrap();
let store_path = cfg.path().join("access.json");
let script_key = "/scripts/tool.lua";
// Run 1: fs.access asks, the user answers "A".
let lua = lua_with(Policy::interactive_scripted(
Some(script_key.into()),
Default::default(),
Some(store_path.clone()),
[Choice::AllowAlways],
));
let ok: bool = lua
.load(format!("return fs.access('{}', 'w')", data_dir.path().display()))
.eval_async()
.await
.unwrap();
assert!(ok);
// The grant is on disk, keyed by script and canonical dir…
let persisted = store::load_for_script(&store_path, script_key);
let dir_key = data_dir.path().canonicalize().unwrap();
assert_eq!(persisted.fs[&dir_key.to_string_lossy().into_owned()].write, Some(true));
// …and a fresh "run" that loads it never needs a prompt, including for a
// file inside a subdirectory.
std::fs::create_dir(data_dir.path().join("sub")).unwrap();
let lua = lua_with(Policy::interactive_scripted(
Some(script_key.into()),
store::load_for_script(&store_path, script_key),
Some(store_path.clone()),
[], // any prompt would deny
));
lua.load(format!(
"fs.writeAll('{}', 'hello')",
data_dir.path().join("sub/out.txt").display()
))
.exec_async()
.await
.unwrap();
}
#[tokio::test]
async fn sqlite_memory_db_works_under_sandbox_but_file_db_is_blocked() {
let lua = lua_with(Policy::deny_all("--sandbox"));
let n: i64 = lua
.load(
r#"
local db = sqlite.connect(":memory:")
db:execute("CREATE TABLE t (x)")
db:execute("INSERT INTO t VALUES (41)")
return db:queryOne("SELECT x + 1 AS y FROM t").y
"#,
)
.eval_async()
.await
.unwrap();
assert_eq!(n, 42);
let dir = tempfile::tempdir().unwrap();
let err = lua_err(
&lua,
&format!("sqlite.connect('{}')", dir.path().join("x.db").display()),
)
.await;
assert!(err.contains("sqlite.connect") && err.contains("--sandbox"), "{err}");
}
#[tokio::test]
async fn sqlite_file_db_asks_one_combined_question() {
let dir = tempfile::tempdir().unwrap();
// A single AllowOnce must cover the whole read/write open.
let lua = lua_with(Policy::interactive_scripted(
None,
Default::default(),
None,
[Choice::AllowOnce],
));
let n: i64 = lua
.load(format!(
r#"
local db = sqlite.connect('{}')
db:execute("CREATE TABLE t (x)")
db:execute("INSERT INTO t VALUES (1)")
return db:queryOne("SELECT COUNT(*) AS n FROM t").n
"#,
dir.path().join("x.db").display()
))
.eval_async()
.await
.unwrap();
assert_eq!(n, 1);
}
#[tokio::test]
async fn http_requests_are_blocked_under_sandbox_before_any_io() {
let lua = lua_with(Policy::deny_all("--sandbox"));
// Unroutable address: if the gate failed, this would time out / error
// differently; the sandbox message must come from our check.
let err = lua_err(&lua, "http.get('http://127.0.0.1:1/x')").await;
assert!(err.contains("networking disabled by --sandbox"), "{err}");
// --no-fs alone leaves networking interactive: the denial names the
// origin (nobody to ask here), not the flag.
let lua = lua_with(deny_fs("--no-fs"));
let err = lua_err(&lua, "http.get('http://127.0.0.1:1/x')").await;
assert!(
err.contains("access to http://127.0.0.1:1 denied") && !err.contains("--no-fs"),
"{err}"
);
}
#[tokio::test]
async fn cookie_jar_files_go_through_the_permission_system() {
let dir = tempfile::tempdir().unwrap();
let jar_path = dir.path().join("jar.jsonl");
// Denied: save must not create the file.
let lua = lua_with(deny_fs("--no-fs"));
let err = lua_err(
&lua,
&format!("local s = http.session() s:save('{}')", jar_path.display()),
)
.await;
assert!(err.contains("session:save") && err.contains("--no-fs"), "{err}");
assert!(!jar_path.exists());
// Allowed: save/load and the http.session(path) preload round-trip.
let lua = lua_allow_all();
lua.load(format!(
r#"
local s = http.session()
s:save('{p}')
s:load('{p}')
local s2 = http.session('{p}')
"#,
p = jar_path.display()
))
.exec_async()
.await
.unwrap();
assert!(jar_path.exists());
}

@ -1,647 +0,0 @@
//! End-to-end tests for the http module against a local `tiny_http` server:
//! request building (form/json/headers/auth), redirect behaviour, the session
//! cookie jar, and jar persistence. Cookie *policy* (public suffix, Secure,
//! expiry) is unit-tested in `stdlib::http`.
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use super::lua as sandboxed_lua;
/// The stdlib Lua with networking and filesystem allowed (the default
/// test policy is deny-all), plus a `BASE` global pointing at `server`.
fn lua_net(server: &TestServer) -> mlua::Lua {
let lua = sandboxed_lua();
lua.set_app_data(Arc::new(crate::stdlib::perm::Policy::allow_all()));
lua.globals().set("BASE", server.base.clone()).unwrap();
lua
}
/// A request as the server saw it, for asserting on from the test thread
/// (a panic inside the server thread would be silently swallowed).
#[derive(Clone, Debug)]
struct Captured {
method: String,
url: String,
headers: Vec<(String, String)>,
body: String,
}
impl Captured {
fn header(&self, name: &str) -> Option<&str> {
self.headers.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str())
}
}
struct TestServer {
base: String,
seen: Arc<Mutex<Vec<Captured>>>,
server: Arc<tiny_http::Server>,
thread: Option<JoinHandle<()>>,
}
impl TestServer {
/// All requests observed so far, in arrival order.
fn requests(&self) -> Vec<Captured> {
self.seen.lock().unwrap().clone()
}
}
impl Drop for TestServer {
fn drop(&mut self) {
self.server.unblock();
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
/// Spawn a local HTTP server; `handler` maps each captured request to a
/// response. Every request is also recorded for later assertions.
fn serve(
handler: impl Fn(&Captured) -> tiny_http::Response<std::io::Cursor<Vec<u8>>>
+ Send
+ Sync
+ 'static,
) -> TestServer {
let server = Arc::new(tiny_http::Server::http("127.0.0.1:0").unwrap());
let base = format!("http://{}", server.server_addr().to_ip().unwrap());
let seen: Arc<Mutex<Vec<Captured>>> = Arc::default();
let thread = {
let server = server.clone();
let seen = seen.clone();
std::thread::spawn(move || {
for mut req in server.incoming_requests() {
let mut body = String::new();
req.as_reader().read_to_string(&mut body).unwrap();
let captured = Captured {
method: req.method().to_string(),
url: req.url().to_string(),
headers: req
.headers()
.iter()
.map(|h| (h.field.to_string().to_ascii_lowercase(), h.value.to_string()))
.collect(),
body,
};
seen.lock().unwrap().push(captured.clone());
let _ = req.respond(handler(&captured));
}
})
};
TestServer { base, seen, server, thread: Some(thread) }
}
fn resp(
code: u16,
headers: &[(&str, &str)],
body: &str,
) -> tiny_http::Response<std::io::Cursor<Vec<u8>>> {
let mut r = tiny_http::Response::from_data(body.as_bytes().to_vec()).with_status_code(code);
for (k, v) in headers {
r.add_header(tiny_http::Header::from_bytes(k.as_bytes(), v.as_bytes()).unwrap());
}
r
}
fn path_of(c: &Captured) -> &str {
c.url.split('?').next().unwrap()
}
#[tokio::test]
async fn test_get_response_fields() {
let server = serve(|_| resp(200, &[("X-One", "1")], "hello"));
let lua = lua_net(&server);
let (status, ok, body, x_one, url): (u16, bool, String, String, String) = lua
.load(
r#"
local r = http.get(BASE)
return r.status, r.ok, r.body, r.headers["x-one"], r.url
"#,
)
.eval_async()
.await
.unwrap();
assert_eq!((status, ok), (200, true));
assert_eq!(body, "hello");
assert_eq!(x_one, "1");
assert_eq!(url, format!("{}/", server.base));
// The default User-Agent goes out unless overridden.
let reqs = server.requests();
assert!(reqs[0].header("user-agent").unwrap().starts_with("luna/"), "{reqs:?}");
}
#[tokio::test]
async fn test_form_body_is_urlencoded() {
let server = serve(|_| resp(200, &[], ""));
let lua = lua_net(&server);
lua.load(r#"http.post(BASE, { form = { q = "a b&c=d", plain = "x", num = 5 } })"#)
.exec_async()
.await
.unwrap();
let req = &server.requests()[0];
assert_eq!(req.method, "POST");
assert_eq!(req.header("content-type"), Some("application/x-www-form-urlencoded"));
// Lua table order is unspecified; compare as a set of encoded pairs.
let mut pairs: Vec<&str> = req.body.split('&').collect();
pairs.sort();
assert_eq!(pairs, ["num=5", "plain=x", "q=a+b%26c%3Dd"]);
}
#[tokio::test]
async fn test_json_body_and_resp_json() {
let server =
serve(|c| resp(200, &[("Content-Type", "application/json")], &c.body.clone()));
let lua = lua_net(&server);
let hello: String = lua
.load(r#"return http.postJSON(BASE, { hello = "world" }).json().hello"#)
.eval_async()
.await
.unwrap();
assert_eq!(hello, "world");
let req = &server.requests()[0];
assert_eq!(req.header("content-type"), Some("application/json"));
assert!(super::json_eq(&req.body, r#"{"hello":"world"}"#));
}
#[tokio::test]
async fn test_user_content_type_wins_over_implied() {
let server = serve(|_| resp(200, &[], ""));
let lua = lua_net(&server);
lua.load(
r#"
http.post(BASE, {
form = { a = "1" },
headers = { ["Content-Type"] = "text/plain" },
})
"#,
)
.exec_async()
.await
.unwrap();
let req = &server.requests()[0];
let cts: Vec<&str> =
req.headers.iter().filter(|(k, _)| k == "content-type").map(|(_, v)| v.as_str()).collect();
assert_eq!(cts, ["text/plain"], "exactly one Content-Type, the caller's");
}
#[tokio::test]
async fn test_redirect_302_degrades_post_to_get() {
let server = serve(|c| match path_of(c) {
"/a" => resp(302, &[("Location", "/b")], ""),
"/b" => resp(200, &[], "final"),
p => resp(404, &[], p),
});
let lua = lua_net(&server);
let (status, body, url): (u16, String, String) = lua
.load(
r#"
local r = http.post(BASE .. "/a", { json = { x = 1 } })
return r.status, r.body, r.url
"#,
)
.eval_async()
.await
.unwrap();
assert_eq!((status, body.as_str()), (200, "final"));
assert!(url.ends_with("/b"), "{url}");
let reqs = server.requests();
assert_eq!(reqs[1].method, "GET");
assert_eq!(reqs[1].body, "", "302-degraded GET must not carry the POST body");
}
#[tokio::test]
async fn test_redirect_307_preserves_method_and_body() {
let server = serve(|c| match path_of(c) {
"/a" => resp(307, &[("Location", "/b")], ""),
_ => resp(200, &[], ""),
});
let lua = lua_net(&server);
lua.load(r#"http.post(BASE .. "/a", { body = "payload" })"#).exec_async().await.unwrap();
let reqs = server.requests();
assert_eq!(reqs[1].method, "POST");
assert_eq!(reqs[1].body, "payload");
}
#[tokio::test]
async fn test_too_many_redirects_errors() {
let server = serve(|_| resp(302, &[("Location", "/loop")], ""));
let lua = lua_net(&server);
let err =
lua.load(r#"http.get(BASE .. "/loop")"#).exec_async().await.unwrap_err().to_string();
assert!(err.contains("http:") && err.contains("redirect"), "{err}");
}
#[tokio::test]
async fn test_session_captures_set_cookie_on_redirect() {
// The login pattern: Set-Cookie arrives on the 302 itself, and the
// redirected hop must already send it back.
let server = serve(|c| match path_of(c) {
"/login" => resp(302, &[("Location", "/dash"), ("Set-Cookie", "sid=abc; Path=/")], ""),
"/dash" => resp(200, &[], ""),
p => resp(404, &[], p),
});
let lua = lua_net(&server);
let (sid, n): (String, i64) = lua
.load(
r#"
local s = http.session()
s:get(BASE .. "/login")
local jar = s:cookies()
local domains = 0
for _ in pairs(jar) do domains = domains + 1 end
return jar["127.0.0.1"].sid, domains
"#,
)
.eval_async()
.await
.unwrap();
assert_eq!(sid, "abc");
assert_eq!(n, 1);
let reqs = server.requests();
assert_eq!(reqs[0].header("cookie"), None);
assert_eq!(reqs[1].header("cookie"), Some("sid=abc"), "jar cookie must reach /dash");
}
#[tokio::test]
async fn test_session_clear_cookies() {
let server = serve(|c| match path_of(c) {
"/set" => resp(200, &[("Set-Cookie", "sid=abc")], ""),
_ => resp(200, &[], ""),
});
let lua = lua_net(&server);
lua.load(
r#"
local s = http.session()
s:get(BASE .. "/set")
s:clearCookies()
assert(next(s:cookies()) == nil, "jar must be empty")
s:get(BASE .. "/check")
"#,
)
.exec_async()
.await
.unwrap();
let reqs = server.requests();
assert_eq!(reqs[1].header("cookie"), None);
}
#[tokio::test]
async fn test_per_request_cookies_merge_with_jar() {
let server = serve(|c| match path_of(c) {
"/set" => resp(200, &[("Set-Cookie", "a=1"), ("Set-Cookie", "keep=yes")], ""),
_ => resp(200, &[], ""),
});
let lua = lua_net(&server);
lua.load(
r#"
local s = http.session()
s:get(BASE .. "/set")
s:get(BASE .. "/check", { cookies = { a = "2", b = "3" } })
"#,
)
.exec_async()
.await
.unwrap();
let reqs = server.requests();
let mut got: Vec<&str> = reqs[1].header("cookie").unwrap().split("; ").collect();
got.sort();
// Jar cookie `keep` rides along, per-request `a` wins over the jar's.
assert_eq!(got, ["a=2", "b=3", "keep=yes"]);
}
#[tokio::test]
async fn test_cookies_opt_conflicts_with_cookie_header() {
let server = serve(|_| resp(200, &[], ""));
let lua = lua_net(&server);
let err = lua
.load(r#"http.get(BASE, { cookies = { a = "1" }, headers = { Cookie = "b=2" } })"#)
.exec_async()
.await
.unwrap_err()
.to_string();
assert!(err.contains("not both"), "{err}");
assert!(server.requests().is_empty(), "the request must not go out");
}
#[tokio::test]
async fn test_session_save_load_roundtrip() {
let server = serve(|c| match path_of(c) {
"/set" => resp(200, &[("Set-Cookie", "sid=abc")], ""),
_ => resp(200, &[], ""),
});
let lua = lua_net(&server);
let dir = tempfile::tempdir().unwrap();
let jar_path = dir.path().join("jar.jsonl");
lua.globals().set("JAR", jar_path.to_str().unwrap()).unwrap();
lua.load(
r#"
local s = http.session()
s:get(BASE .. "/set")
s:save(JAR)
-- A fresh session preloaded from the file sends the cookie again.
local s2 = http.session(JAR)
assert(s2:cookies()["127.0.0.1"].sid == "abc", "cookie must survive save/load")
s2:get(BASE .. "/check")
"#,
)
.exec_async()
.await
.unwrap();
let reqs = server.requests();
assert_eq!(reqs[1].header("cookie"), Some("sid=abc"));
}
#[tokio::test]
async fn test_basic_auth_header() {
let server = serve(|_| resp(200, &[], ""));
let lua = lua_net(&server);
lua.load(r#"http.get(BASE, { auth = { username = "alice", password = "secret" } })"#)
.exec_async()
.await
.unwrap();
// base64("alice:secret")
let auth = server.requests()[0].header("authorization").unwrap().to_string();
assert_eq!(auth, "Basic YWxpY2U6c2VjcmV0");
}
#[tokio::test]
async fn test_digest_auth_answers_challenge() {
let server = serve(|c| {
if c.header("authorization").is_none() {
resp(
401,
&[("WWW-Authenticate", r#"Digest realm="test", nonce="abc123", qop="auth""#)],
"",
)
} else {
resp(200, &[], "in")
}
});
let lua = lua_net(&server);
let (ok, body): (bool, String) = lua
.load(
r#"
local r = http.get(BASE .. "/p", {
auth = { username = "alice", password = "secret", scheme = "digest" },
})
return r.ok, r.body
"#,
)
.eval_async()
.await
.unwrap();
assert!(ok);
assert_eq!(body, "in");
let reqs = server.requests();
assert_eq!(reqs.len(), 2, "one challenge, one answer");
let auth = reqs[1].header("authorization").unwrap();
assert!(auth.starts_with("Digest "), "{auth}");
for part in [r#"username="alice""#, r#"uri="/p""#, "response="] {
assert!(auth.contains(part), "{auth} missing {part}");
}
}
#[tokio::test]
async fn test_cross_host_redirect_strips_credentials() {
// Two servers = two hosts (the port differs). Authorization and Cookie
// must not follow the redirect off the original host; ordinary custom
// headers do (matching browser/curl behaviour).
let target = serve(|_| resp(200, &[], "landed"));
let origin = {
let to = format!("{}/land", target.base);
serve(move |_| resp(302, &[("Location", &to.clone())], ""))
};
let lua = lua_net(&origin);
let body: String = lua
.load(
r#"
local r = http.get(BASE .. "/go", {
auth = { username = "alice", password = "secret" },
cookies = { sid = "abc" },
headers = { ["X-Custom"] = "keep" },
})
return r.body
"#,
)
.eval_async()
.await
.unwrap();
assert_eq!(body, "landed");
let first = &origin.requests()[0];
assert!(first.header("authorization").is_some());
assert!(first.header("cookie").is_some());
let landed = &target.requests()[0];
assert_eq!(landed.header("authorization"), None, "auth leaked cross-host");
assert_eq!(landed.header("cookie"), None, "cookies leaked cross-host");
assert_eq!(landed.header("x-custom"), Some("keep"));
}
#[tokio::test]
async fn test_multi_value_headers_and_set_cookies() {
let server = serve(|_| {
resp(
200,
&[
("X-Multi", "one"),
("X-Multi", "two"),
("Set-Cookie", "a=1; Path=/"),
("Set-Cookie", "b=2; Path=/"),
],
"",
)
});
let lua = lua_net(&server);
let (multi, first_sc, sc1, sc2): (String, String, String, String) = lua
.load(
r#"
local r = http.get(BASE)
assert(#r.setCookies == 2, "expected two Set-Cookie values")
return r.headers["x-multi"], r.headers["set-cookie"], r.setCookies[1], r.setCookies[2]
"#,
)
.eval_async()
.await
.unwrap();
assert_eq!(multi, "one, two");
assert_eq!(first_sc, "a=1; Path=/");
assert_eq!((sc1.as_str(), sc2.as_str()), ("a=1; Path=/", "b=2; Path=/"));
}
#[tokio::test]
async fn test_timeout_errors() {
let server = serve(|_| {
std::thread::sleep(std::time::Duration::from_secs(2));
resp(200, &[], "late")
});
let lua = lua_net(&server);
let start = std::time::Instant::now();
let err = lua
.load(r#"http.get(BASE, { timeout = 0.1 })"#)
.exec_async()
.await
.unwrap_err()
.to_string();
assert!(err.contains("http:"), "{err}");
assert!(start.elapsed() < std::time::Duration::from_secs(1), "timeout did not apply");
}
#[tokio::test]
async fn test_invalid_url_and_method() {
let server = serve(|_| resp(200, &[], ""));
let lua = lua_net(&server);
let err = lua.load(r#"http.get("not a url")"#).exec_async().await.unwrap_err().to_string();
assert!(err.contains("invalid URL"), "{err}");
let err = lua
.load(r#"http.request("BAD METHOD", BASE)"#)
.exec_async()
.await
.unwrap_err()
.to_string();
assert!(err.contains("invalid method"), "{err}");
assert!(server.requests().is_empty());
}
// ---------------------------------------------------------------------------
// Origin permissions
// ---------------------------------------------------------------------------
/// The stdlib Lua with an interactive policy fed by scripted prompt answers
/// (and a `BASE` global), for exercising the per-origin permission gate.
fn lua_scripted(
server: &TestServer,
script_key: Option<&str>,
store_path: Option<std::path::PathBuf>,
answers: impl IntoIterator<Item = crate::stdlib::perm::Choice>,
) -> mlua::Lua {
let lua = sandboxed_lua();
lua.set_app_data(Arc::new(crate::stdlib::perm::Policy::interactive_scripted(
script_key.map(String::from),
Default::default(),
store_path,
answers,
)));
lua.globals().set("BASE", server.base.clone()).unwrap();
lua
}
#[tokio::test]
async fn test_origin_denied_blocks_request_before_any_io() {
let server = serve(|_| resp(200, &[], "ok"));
// No scripted answers = nobody to ask: deny.
let lua = lua_scripted(&server, None, None, []);
let err = lua.load("http.get(BASE .. '/x')").exec_async().await.unwrap_err().to_string();
assert!(
err.contains(&format!("access to {} denied", server.base)),
"{err}"
);
assert!(server.requests().is_empty(), "the request must never leave the process");
}
#[tokio::test]
async fn test_origin_allow_once_covers_the_whole_run() {
use crate::stdlib::perm::Choice;
let server = serve(|_| resp(200, &[], "ok"));
// Exactly one answer: the second request must be served from the session
// grant, not a second prompt.
let lua = lua_scripted(&server, None, None, [Choice::AllowOnce]);
let (a, b): (String, String) = lua
.load("return http.get(BASE .. '/one').body, http.get(BASE .. '/two').body")
.eval_async()
.await
.unwrap();
assert_eq!((a.as_str(), b.as_str()), ("ok", "ok"));
assert_eq!(server.requests().len(), 2);
}
#[tokio::test]
async fn test_origin_allow_always_persists_under_net_key() {
use crate::stdlib::perm::{store, Choice};
let server = serve(|_| resp(200, &[], "ok"));
let cfg = tempfile::tempdir().unwrap();
let store_path = cfg.path().join("access.json");
// The URL carries credentials and a path; the recorded key must be the
// bare origin (BASE = scheme://host:port).
let lua = lua_scripted(
&server,
Some("/scripts/tool.lua"),
Some(store_path.clone()),
[Choice::AllowAlways],
);
let with_creds = server.base.replace("http://", "http://user:secret@");
lua.load(format!("http.get('{with_creds}/x?q=1')")).exec_async().await.unwrap();
let saved = store::load_for_script(&store_path, "/scripts/tool.lua");
assert_eq!(saved.net[&server.base].access, Some(true));
assert_eq!(saved.net.len(), 1, "exactly one origin recorded: {:?}", saved.net);
assert!(saved.fs.is_empty());
// and the raw JSON nests it under the script's "net" section
let raw = std::fs::read_to_string(&store_path).unwrap();
assert!(raw.contains("\"net\""), "{raw}");
// A fresh run loading the store needs no prompt (no answers scripted).
let lua = sandboxed_lua();
lua.set_app_data(Arc::new(crate::stdlib::perm::Policy::interactive_scripted(
Some("/scripts/tool.lua".into()),
store::load_for_script(&store_path, "/scripts/tool.lua"),
Some(store_path),
[],
)));
lua.globals().set("BASE", server.base.clone()).unwrap();
let body: String = lua.load("return http.get(BASE .. '/y').body").eval_async().await.unwrap();
assert_eq!(body, "ok");
}
#[tokio::test]
async fn test_origin_deny_answer_blocks_request() {
use crate::stdlib::perm::Choice;
let server = serve(|_| resp(200, &[], "ok"));
let lua = lua_scripted(&server, None, None, [Choice::DenyOnce]);
let err = lua.load("http.get(BASE .. '/x')").exec_async().await.unwrap_err().to_string();
assert!(err.contains("denied"), "{err}");
assert!(server.requests().is_empty());
}

@ -1,398 +0,0 @@
//! Tests for the math stdlib extensions (lua/stdlib/math.lua).
use super::assert_eq_f64;
use mlua::prelude::LuaValue;
#[test]
fn test_clamp() {
let lua = super::lua();
// Simple, integers
let result: i64 = lua.load(r#"return math.clamp(3, 10, 20)"#).eval().unwrap();
assert_eq!(result, 10); // low
let result: i64 = lua.load(r#"return math.clamp(3, nil, 20)"#).eval().unwrap();
assert_eq!(result, 3); // low-unbounded
let result: i64 = lua.load(r#"return math.clamp(15, 10, 20)"#).eval().unwrap();
assert_eq!(result, 15); // inside
let result: i64 = lua.load(r#"return math.clamp(25, 10, 20)"#).eval().unwrap();
assert_eq!(result, 20); // high
let result: i64 = lua.load(r#"return math.clamp(25, 10, nil)"#).eval().unwrap();
assert_eq!(result, 25); // high-unbounded
let result: i64 = lua.load(r#"return math.clamp(999, nil, nil)"#).eval().unwrap();
assert_eq!(result, 999); // unbounded
// Floats
let result: f64 = lua.load(r#"return math.clamp(3.5, 10.5, 20.5)"#).eval().unwrap();
assert_eq_f64!(result, 10.5); // low
let result: f64 = lua.load(r#"return math.clamp(3.5, nil, 20.5)"#).eval().unwrap();
assert_eq_f64!(result, 3.5); // low-unbounded
let result: f64 = lua.load(r#"return math.clamp(15.5, 10.5, 20.5)"#).eval().unwrap();
assert_eq_f64!(result, 15.5); // inside
let result: f64 = lua.load(r#"return math.clamp(25.5, 10.5, 20.5)"#).eval().unwrap();
assert_eq_f64!(result, 20.5); // high
let result: f64 = lua.load(r#"return math.clamp(25.5, 10.5, nil)"#).eval().unwrap();
assert_eq_f64!(result, 25.5); // high-unbounded
let result: f64 = lua.load(r#"return math.clamp(999.9, nil, nil)"#).eval().unwrap();
assert_eq_f64!(result, 999.9); // unbounded
// Check some negative numbers
let result: i64 = lua.load(r#"return math.clamp(3, -10, 20)"#).eval().unwrap();
assert_eq!(result, 3); // low
let result: i64 = lua.load(r#"return math.clamp(-15, -10, 20)"#).eval().unwrap();
assert_eq!(result, -10); // inside
let result: i64 = lua.load(r#"return math.clamp(-25, -10, 20)"#).eval().unwrap();
assert_eq!(result, -10); // high
let result: i64 = lua.load(r#"return math.clamp(25, -10, 20)"#).eval().unwrap();
assert_eq!(result, 20); // high
assert!(lua.load(r#"return math.clamp(nil, 1, 2)"#).eval::<LuaValue>().is_err());
assert!(lua.load(r#"return math.clamp(1, "1", 2)"#).eval::<LuaValue>().is_err());
assert!(lua.load(r#"return math.clamp(1, "foo", 2)"#).eval::<LuaValue>().is_err());
assert!(lua.load(r#"return math.clamp(1, 1, "bar")"#).eval::<LuaValue>().is_err());
assert!(lua.load(r#"return math.clamp(1, 1, "2")"#).eval::<LuaValue>().is_err());
// Crossed bounds (min > max) is a programming error, not a valid interval
let err = lua.load(r#"return math.clamp(5, 10, 0)"#).exec().unwrap_err();
assert!(err.to_string().contains("greater than"));
}
#[test]
fn test_round() {
let lua = super::lua();
// Round positive numbers
let result: i64 = lua.load(r#"return math.round(3.4)"#).eval().unwrap();
assert_eq!(result, 3);
let result: i64 = lua.load(r#"return math.round(3.5)"#).eval().unwrap();
assert_eq!(result, 4);
let result: i64 = lua.load(r#"return math.round(3.9)"#).eval().unwrap();
assert_eq!(result, 4);
// Round negative numbers
let result: i64 = lua.load(r#"return math.round(-3.4)"#).eval().unwrap();
assert_eq!(result, -3);
let result: i64 = lua.load(r#"return math.round(-3.5)"#).eval().unwrap();
assert_eq!(result, -4);
// Round whole numbers (should stay the same)
let result: i64 = lua.load(r#"return math.round(5)"#).eval().unwrap();
assert_eq!(result, 5);
let result: i64 = lua.load(r#"return math.round(0)"#).eval().unwrap();
assert_eq!(result, 0);
// Round with a non-number should error
let err = lua.load(r#"return math.round("not a number")"#).exec().unwrap_err();
assert!(err.to_string().contains("non-number"));
// NaN must error, not silently become 0
let err = lua.load(r#"return math.round(0/0)"#).exec().unwrap_err();
assert!(err.to_string().contains("finite"));
// Infinity must error (out of integer range)
assert!(lua.load(r#"return math.round(math.huge)"#).exec().is_err());
assert!(lua.load(r#"return math.round(-math.huge)"#).exec().is_err());
// Out of i64 range must error
assert!(lua.load(r#"return math.round(1e19)"#).exec().is_err());
assert!(lua.load(r#"return math.round(-1e19)"#).exec().is_err());
// Half away from zero at exactly +-0.5
let result: i64 = lua.load(r#"return math.round(0.5)"#).eval().unwrap();
assert_eq!(result, 1);
let result: i64 = lua.load(r#"return math.round(-0.5)"#).eval().unwrap();
assert_eq!(result, -1);
// The naive floor(value + 0.5) trap: 0.49999999999999994 + 0.5 == 1.0 in f64,
// but the nearest integer is 0
let result: i64 = lua.load(r#"return math.round(0.49999999999999994)"#).eval().unwrap();
assert_eq!(result, 0);
let result: i64 = lua.load(r#"return math.round(-0.49999999999999994)"#).eval().unwrap();
assert_eq!(result, 0);
// Integer-valued floats at the edge of f64 precision (2^53)
let result: i64 = lua.load(r#"return math.round(2.0^53 - 1.0)"#).eval().unwrap();
assert_eq!(result, 9007199254740991);
let result: i64 = lua.load(r#"return math.round(2.0^53)"#).eval().unwrap();
assert_eq!(result, 9007199254740992);
// -2^63 is exactly representable as a float and is a valid Lua integer...
let result: bool = lua.load(r#"return math.round(-2.0^63) == math.mininteger"#).eval().unwrap();
assert!(result);
// ...but 2^63 is one above math.maxinteger and must error
assert!(lua.load(r#"return math.round(2.0^63)"#).exec().is_err());
}
//noinspection RsApproxConstant
#[test]
fn test_round_to_places() {
let lua = super::lua();
// roundn was merged into round - it must no longer exist
let result: bool = lua.load(r#"return math.roundn == nil"#).eval().unwrap();
assert!(result);
// places = 0 behaves like the one-argument form and returns an integer
let result: String = lua.load(r#"return math.type(math.round(3.1415, 0))"#).eval().unwrap();
assert_eq!(result, "integer");
let result: i64 = lua.load(r#"return math.round(3.7, 0)"#).eval().unwrap();
assert_eq!(result, 4);
// places > 0 rounds to N decimal places and returns a float
let result: f64 = lua.load(r#"return math.round(3.1415, 1)"#).eval().unwrap();
assert_eq_f64!(result, 3.1);
let result: f64 = lua.load(r#"return math.round(3.1415, 3)"#).eval().unwrap();
assert_eq_f64!(result, 3.142);
let result: f64 = lua.load(r#"return math.round(2.567, 2)"#).eval().unwrap();
assert_eq_f64!(result, 2.57);
let result: String = lua.load(r#"return math.type(math.round(3.1415, 2))"#).eval().unwrap();
assert_eq!(result, "float");
// Integer input with places > 0 returns the same value as a float
let result: String = lua.load(r#"return math.type(math.round(5, 2))"#).eval().unwrap();
assert_eq!(result, "float");
let result: f64 = lua.load(r#"return math.round(5, 2)"#).eval().unwrap();
assert_eq_f64!(result, 5.0);
// Negative numbers round half away from zero
let result: f64 = lua.load(r#"return math.round(-2.345, 2)"#).eval().unwrap();
assert_eq_f64!(result, -2.35);
// Huge values must not overflow to infinity via the 10^places multiplier
let result: f64 = lua.load(r#"return math.round(1.5e308, 2)"#).eval().unwrap();
assert_eq_f64!(result, 1.5e308);
// Huge places are a no-op (cannot change any f64), not an overflow
let result: f64 = lua.load(r#"return math.round(12.5, 400)"#).eval().unwrap();
assert_eq_f64!(result, 12.5);
// Tiny values still round correctly with large places
let result: f64 = lua.load(r#"return math.round(1.23e-300, 300)"#).eval().unwrap();
assert_eq_f64!(result, 1.0e-300);
// Places given as an integral float is accepted
let result: f64 = lua.load(r#"return math.round(3.14159, 2.0)"#).eval().unwrap();
assert_eq_f64!(result, 3.14);
// Non-integral places must error
assert!(lua.load(r#"return math.round(3.14, 2.5)"#).exec().is_err());
// Negative places must error with a clear message
let err = lua.load(r#"return math.round(3.14, -2)"#).exec().unwrap_err();
assert!(err.to_string().contains("negative"));
// NaN must error
let err = lua.load(r#"return math.round(0/0, 2)"#).exec().unwrap_err();
assert!(err.to_string().contains("finite"));
// Infinity must error
assert!(lua.load(r#"return math.round(math.huge, 2)"#).exec().is_err());
// Non-number must error
let err = lua.load(r#"return math.round("foo", 2)"#).exec().unwrap_err();
assert!(err.to_string().contains("non-number"));
}
#[test]
fn test_is_finite() {
let lua = super::lua();
// Regular numbers are finite
let result: bool = lua.load(r#"return math.isFinite(42)"#).eval().unwrap();
assert!(result);
let result: bool = lua.load(r#"return math.isFinite(-3.14)"#).eval().unwrap();
assert!(result);
let result: bool = lua.load(r#"return math.isFinite(0)"#).eval().unwrap();
assert!(result);
// Infinity is not finite
let result: bool = lua.load(r#"return math.isFinite(math.huge)"#).eval().unwrap();
assert!(!result);
let result: bool = lua.load(r#"return math.isFinite(-math.huge)"#).eval().unwrap();
assert!(!result);
// NaN is not finite (0/0 produces NaN)
let result: bool = lua.load(r#"return math.isFinite(0/0)"#).eval().unwrap();
assert!(!result);
// Non-numbers return false
let result: bool = lua.load(r#"return math.isFinite("hello")"#).eval().unwrap();
assert!(!result);
let result: bool = lua.load(r#"return math.isFinite(nil)"#).eval().unwrap();
assert!(!result);
let result: bool = lua.load(r#"return math.isFinite({})"#).eval().unwrap();
assert!(!result);
// Very large numbers are still finite
let result: bool = lua.load(r#"return math.isFinite(1e308)"#).eval().unwrap();
assert!(result);
// But overflow to infinity is not
let result: bool = lua.load(r#"return math.isFinite(1e309)"#).eval().unwrap();
assert!(!result);
}
#[test]
fn test_sign() {
let lua = super::lua();
// Positive numbers return 1
let result: i64 = lua.load(r#"return math.sign(42)"#).eval().unwrap();
assert_eq!(result, 1);
let result: i64 = lua.load(r#"return math.sign(0.001)"#).eval().unwrap();
assert_eq!(result, 1);
// Negative numbers return -1
let result: i64 = lua.load(r#"return math.sign(-42)"#).eval().unwrap();
assert_eq!(result, -1);
let result: i64 = lua.load(r#"return math.sign(-0.001)"#).eval().unwrap();
assert_eq!(result, -1);
// Zero returns 0
let result: i64 = lua.load(r#"return math.sign(0)"#).eval().unwrap();
assert_eq!(result, 0);
// Negative zero is still zero
let result: i64 = lua.load(r#"return math.sign(-0)"#).eval().unwrap();
assert_eq!(result, 0);
// Positive infinity returns 1
let result: i64 = lua.load(r#"return math.sign(math.huge)"#).eval().unwrap();
assert_eq!(result, 1);
// Negative infinity returns -1
let result: i64 = lua.load(r#"return math.sign(-math.huge)"#).eval().unwrap();
assert_eq!(result, -1);
// NaN returns 0 (NaN is not > 0 and not < 0)
let result: i64 = lua.load(r#"return math.sign(0/0)"#).eval().unwrap();
assert_eq!(result, 0);
// Error on non-number
let err = lua.load(r#"return math.sign("foo")"#).exec().unwrap_err();
assert!(err.to_string().contains("Non-number"));
}
#[test]
fn test_scale() {
let lua = super::lua();
// Scale 0-100 to 0-1
let result: f64 = lua.load(r#"return math.scale(50, 0, 100, 0, 1)"#).eval().unwrap();
assert_eq_f64!(result, 0.5);
// Scale 0-100 to 0-10
let result: f64 = lua.load(r#"return math.scale(25, 0, 100, 0, 10)"#).eval().unwrap();
assert_eq_f64!(result, 2.5);
// Scale with offset ranges
let result: f64 = lua.load(r#"return math.scale(15, 10, 20, 100, 200)"#).eval().unwrap();
assert_eq_f64!(result, 150.0);
// Scale to inverted range
let result: f64 = lua.load(r#"return math.scale(0, 0, 100, 100, 0)"#).eval().unwrap();
assert_eq_f64!(result, 100.0);
let result: f64 = lua.load(r#"return math.scale(100, 0, 100, 100, 0)"#).eval().unwrap();
assert_eq_f64!(result, 0.0);
// Value outside input range (no clamp)
let result: f64 = lua.load(r#"return math.scale(150, 0, 100, 0, 10)"#).eval().unwrap();
assert_eq_f64!(result, 15.0);
let result: f64 = lua.load(r#"return math.scale(-50, 0, 100, 0, 10)"#).eval().unwrap();
assert_eq_f64!(result, -5.0);
// Value outside input range with clamp=true
let result: f64 = lua.load(r#"return math.scale(150, 0, 100, 0, 10, true)"#).eval().unwrap();
assert_eq_f64!(result, 10.0);
let result: f64 = lua.load(r#"return math.scale(-50, 0, 100, 0, 10, true)"#).eval().unwrap();
assert_eq_f64!(result, 0.0);
// Clamp with inverted output range
let result: f64 = lua.load(r#"return math.scale(150, 0, 100, 10, 0, true)"#).eval().unwrap();
assert_eq_f64!(result, 0.0);
let result: f64 = lua.load(r#"return math.scale(-50, 0, 100, 10, 0, true)"#).eval().unwrap();
assert_eq_f64!(result, 10.0);
// Clamp=false (explicit) behaves same as default
let result: f64 = lua.load(r#"return math.scale(150, 0, 100, 0, 10, false)"#).eval().unwrap();
assert_eq_f64!(result, 15.0);
// Zero-width output range is allowed (always returns outMin)
let result: f64 = lua.load(r#"return math.scale(50, 0, 100, 5, 5)"#).eval().unwrap();
assert_eq_f64!(result, 5.0);
// Boundary values
let result: f64 = lua.load(r#"return math.scale(0, 0, 100, 0, 10)"#).eval().unwrap();
assert_eq_f64!(result, 0.0);
let result: f64 = lua.load(r#"return math.scale(100, 0, 100, 0, 10)"#).eval().unwrap();
assert_eq_f64!(result, 10.0);
// Negative input range
let result: f64 = lua.load(r#"return math.scale(-50, -100, 0, 0, 10)"#).eval().unwrap();
assert_eq_f64!(result, 5.0);
// Inverted input range (inMin > inMax)
let result: f64 = lua.load(r#"return math.scale(75, 100, 0, 0, 10)"#).eval().unwrap();
assert_eq_f64!(result, 2.5);
// Error on zero-width input range (division by zero)
let err = lua.load(r#"return math.scale(50, 100, 100, 0, 10)"#).exec().unwrap_err();
assert!(err.to_string().contains("Input range cannot be zero"));
// Error on non-number value
let err = lua.load(r#"return math.scale("foo", 0, 100, 0, 10)"#).exec().unwrap_err();
assert!(err.to_string().contains("Non-number"));
// Error on non-number range
let err = lua.load(r#"return math.scale(50, "a", 100, 0, 10)"#).exec().unwrap_err();
assert!(err.to_string().contains("Non-number"));
// Infinity input produces infinity output (no clamp)
let result: bool = lua
.load(r#"return math.scale(math.huge, 0, 100, 0, 10) == math.huge"#)
.eval()
.unwrap();
assert!(result);
// Infinity input with clamp gets clamped
let result: f64 = lua.load(r#"return math.scale(math.huge, 0, 100, 0, 10, true)"#).eval().unwrap();
assert_eq_f64!(result, 10.0);
let result: f64 = lua
.load(r#"return math.scale(-math.huge, 0, 100, 0, 10, true)"#)
.eval()
.unwrap();
assert_eq_f64!(result, 0.0);
// NaN input produces NaN output
let result: bool = lua
.load(r#"local r = math.scale(0/0, 0, 100, 0, 10); return r ~= r"#)
.eval()
.unwrap();
assert!(result); // NaN ~= NaN is true
}

@ -1,45 +0,0 @@
//! Tests for the Lua stdlib, ported from flowbox-rt's fb_lua `lua_tests` and
//! adapted to this project's sandbox and stdlib.
mod async_tests;
mod core_tests;
mod fs_tests;
mod http_tests;
mod math_tests;
mod os_tests;
mod require_tests;
mod table_tests;
mod tui_tests;
mod utils_tests;
use mlua::Lua;
/// A fresh sandboxed Lua with the full stdlib installed - the same state scripts get.
pub(crate) fn lua() -> Lua {
crate::sandbox::create_sandboxed_lua().unwrap()
}
/// Compare two JSON strings as parsed values (key order independent).
#[allow(dead_code)]
pub(crate) fn json_eq(a: &str, b: &str) -> bool {
let a: serde_json::Value = serde_json::from_str(a).unwrap();
let b: serde_json::Value = serde_json::from_str(b).unwrap();
a == b
}
/// Assert that two f64 values are equal within an epsilon (default `f64::EPSILON`).
macro_rules! assert_eq_f64 {
($a:expr, $b:expr) => {
assert_eq_f64!($a, $b, f64::EPSILON)
};
($a:expr, $b:expr, $eps:expr) => {{
let (a, b): (f64, f64) = ($a, $b);
let eps: f64 = $eps;
assert!(
(a - b).abs() <= eps,
"assertion failed: `(left !== right)` (left: `{a:?}`, right: `{b:?}`, epsilon: `{eps:?}`, diff: `{:?}`)",
(a - b).abs()
);
}};
}
pub(crate) use assert_eq_f64;

@ -1,212 +0,0 @@
//! Tests for the sandboxed `os` table.
//!
//! Ported from flowbox-rt's fb_lua os_tests. Unlike flowbox, this project
//! keeps the real Lua 5.5 os.date/os.time/os.difftime (flowbox used a
//! chrono-based shim), so a few edge cases differ - see individual tests.
//! os.sleep is async here and is covered by async_tests, not this file.
use std::time::{Duration, Instant};
#[test]
fn test_os_time_now() {
let lua = super::lua();
let result: i64 = lua.load(r#"return os.time()"#).eval().unwrap();
let now = chrono::Utc::now().timestamp();
assert!((result - now).abs() <= 5, "os.time() = {result}, expected ~{now}");
}
#[test]
fn test_os_time_from_table() {
let lua = super::lua();
// Round trip through local time - independent of the host timezone
let ok: bool = lua
.load(
r#"
local t = os.time({year = 2020, month = 1, day = 15, hour = 10, min = 30, sec = 20})
local d = os.date("*t", t)
return d.year == 2020 and d.month == 1 and d.day == 15
and d.hour == 10 and d.min == 30 and d.sec == 20
"#,
)
.eval()
.unwrap();
assert!(ok);
// Out-of-range fields are normalized like mktime; hour defaults to 12
let ok: bool = lua
.load(
r#"
local a = os.time({year = 2020, month = 14, day = 1})
local b = os.time({year = 2021, month = 2, day = 1})
return a == b
"#,
)
.eval()
.unwrap();
assert!(ok);
// The normalized fields are written back into the table
let ok: bool = lua
.load(
r#"
local tbl = {year = 2020, month = 14, day = 1}
os.time(tbl)
return tbl.year == 2021 and tbl.month == 2 and tbl.day == 1
and tbl.hour == 12 and tbl.wday == 2 -- 2021-02-01 was a Monday
"#,
)
.eval()
.unwrap();
assert!(ok);
// Missing mandatory field is an error
let ok: bool = lua.load(r#"return (pcall(os.time, {year = 2020}))"#).eval().unwrap();
assert!(!ok);
}
#[test]
fn test_os_date_format() {
let lua = super::lua();
let result: String = lua.load(r#"return os.date("!%Y-%m-%dT%H:%M:%S", 1000000000)"#).eval().unwrap();
assert_eq!(result, "2001-09-09T01:46:40");
// Epoch
let result: String = lua.load(r#"return os.date("!%Y-%m-%d %H:%M:%S", 0)"#).eval().unwrap();
assert_eq!(result, "1970-01-01 00:00:00");
// Stock Lua difference: flowbox's chrono shim truncated float time values;
// real Lua 5.5 requires an integer-representable time and raises an error.
let ok: bool = lua.load(r#"return (pcall(os.date, "!%Y-%m-%d", 0.9))"#).eval().unwrap();
assert!(!ok, "os.date with a fractional time value should raise in stock Lua");
// Default format is %c, applied to the current time
let result: String = lua.load(r#"return os.date()"#).eval().unwrap();
assert!(!result.is_empty());
// Invalid format specifier is an error, not garbage output
let ok: bool = lua.load(r#"return (pcall(os.date, "%Q"))"#).eval().unwrap();
assert!(!ok);
}
#[test]
fn test_os_date_table() {
let lua = super::lua();
// 2001-09-09T01:46:40Z was a Sunday, day 252 of the year
let ok: bool = lua
.load(
r#"
local d = os.date("!*t", 1000000000)
return d.year == 2001 and d.month == 9 and d.day == 9
and d.hour == 1 and d.min == 46 and d.sec == 40
and d.wday == 1 and d.yday == 252
"#,
)
.eval()
.unwrap();
assert!(ok);
}
/// os.clock is replaced with monotonic wall time since state creation.
#[test]
fn test_os_clock() {
let start = Instant::now();
let lua = super::lua();
std::thread::sleep(Duration::from_millis(30));
let clock: f64 = lua.load(r#"return os.clock()"#).eval().unwrap();
assert!(
(start.elapsed().as_secs_f64() - clock).abs() < 0.1,
"Clock function works"
);
// 200 ms elapses, so it should be reported accurately
std::thread::sleep(Duration::from_millis(200));
let clock2: f64 = lua.load(r#"return os.clock()"#).eval().unwrap();
assert!((clock2 - clock - 0.2) < 0.03, "Clock tracks time");
}
#[test]
fn test_os_difftime() {
let lua = super::lua();
let result: f64 = lua.load(r#"return os.difftime(10, 4)"#).eval().unwrap();
assert_eq!(result, 6.0);
let ok: bool = lua.load(r#"return (pcall(os.difftime, 10))"#).eval().unwrap();
assert!(!ok);
}
#[test]
fn test_os_missing_dangerous_functions() {
let lua = super::lua();
let os_table = lua.globals().get::<mlua::Table>("os").unwrap();
for name in ["execute", "exit", "getenv", "remove", "rename", "setlocale", "tmpname"] {
assert!(
!os_table.contains_key(name).unwrap(),
"os.{name} should be removed from the sandbox"
);
}
}
#[test]
fn test_os_args() {
let lua = super::lua();
// Without ScriptArgs app data (tests, and structurally the REPL): empty.
let n: i64 = lua.load(r#"return #os.args()"#).eval().unwrap();
assert_eq!(n, 0);
// main plants the parsed trailing arguments; non-UTF-8 bytes pass through.
use std::os::unix::ffi::OsStringExt;
lua.set_app_data(crate::stdlib::os_ext::ScriptArgs(vec![
"hello".into(),
"--flag".into(),
std::ffi::OsString::from_vec(vec![0xff, b'x']),
]));
let ok: bool = lua
.load(
r#"
local a = os.args()
return #a == 3 and a[1] == "hello" and a[2] == "--flag" and a[3] == "\xffx"
"#,
)
.eval()
.unwrap();
assert!(ok);
// Each call returns a fresh table — mutations do not leak into the next.
let ok: bool = lua
.load(
r#"
os.args()[1] = "changed"
return os.args()[1] == "hello"
"#,
)
.eval()
.unwrap();
assert!(ok);
}
#[test]
fn test_os_microtime() {
let lua = super::lua();
let result1: f64 = lua.load(r#"return os.microtime()"#).eval().unwrap();
std::thread::sleep(Duration::from_millis(100));
let result2: f64 = lua.load(r#"return os.microtime()"#).eval().unwrap();
assert!(
(result2 - result1 - 0.1).abs() < 0.03,
"os.microtime() should return fractional seconds"
);
// It is a Unix timestamp
let now = chrono::Utc::now().timestamp() as f64;
assert!((result2 - now).abs() <= 5.0, "os.microtime() = {result2}, expected ~{now}");
}

@ -1,262 +0,0 @@
//! Tests for `require`: resolution, caching, isolation and failure modes.
//! Each test builds its own module tree under a unique temp directory and
//! installs `require` with that directory as a search root, the same way
//! `main.rs` does with the script's directory.
use std::path::PathBuf;
use mlua::Lua;
use super::lua;
/// A per-test module tree; removed on drop.
struct TempRoot(PathBuf);
impl TempRoot {
fn new(tag: &str) -> Self {
let dir = std::env::temp_dir().join(format!("a-require-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
TempRoot(dir)
}
fn write(&self, rel: &str, contents: &str) {
let path = self.0.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, contents).unwrap();
}
}
impl Drop for TempRoot {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn lua_with_roots(roots: &[&TempRoot]) -> Lua {
let lua = lua();
let roots: Vec<PathBuf> = roots.iter().map(|r| r.0.clone()).collect();
crate::stdlib::require::install(&lua, roots).unwrap();
lua
}
#[tokio::test]
async fn test_require_returns_value_and_path() {
let root = TempRoot::new("value");
root.write("mod.lua", "return { answer = 42 }");
let lua = lua_with_roots(&[&root]);
let (answer, path): (i64, String) = lua
.load(r#"local m, p = require "mod"; return m.answer, p"#)
.eval_async()
.await
.unwrap();
assert_eq!(answer, 42);
assert!(path.ends_with("mod.lua"), "{path}");
}
#[tokio::test]
async fn test_require_caches_and_runs_once() {
let root = TempRoot::new("cache");
root.write("counted.lua", "COUNT = (COUNT or 0) + 1\nreturn { n = COUNT }");
let lua = lua_with_roots(&[&root]);
let (same, count): (bool, i64) = lua
.load(
r#"
local a = require "counted"
local b = require "counted"
return rawequal(a, b), COUNT
"#,
)
.eval_async()
.await
.unwrap();
assert!(same, "both requires must return the same table");
assert_eq!(count, 1, "module body must run exactly once");
}
#[tokio::test]
async fn test_require_dot_names_and_init_fallback() {
let root = TempRoot::new("dots");
root.write("db/migrations.lua", r#"return "migrations""#);
root.write("pkg/init.lua", r#"return "pkg-init""#);
let lua = lua_with_roots(&[&root]);
let (a, b): (String, String) = lua
.load(r#"return require "db.migrations", require "pkg""#)
.eval_async()
.await
.unwrap();
assert_eq!(a, "migrations");
assert_eq!(b, "pkg-init");
}
#[tokio::test]
async fn test_require_module_locals_stay_local() {
let root = TempRoot::new("scope");
root.write("scoped.lua", "local secret = 'hidden'\nreturn true");
let lua = lua_with_roots(&[&root]);
let leaked: bool = lua
.load(r#"require "scoped"; return secret ~= nil"#)
.eval_async()
.await
.unwrap();
assert!(!leaked, "module locals must not leak into globals");
}
#[tokio::test]
async fn test_require_passes_name_and_path_as_varargs() {
let root = TempRoot::new("varargs");
root.write("who.lua", "local name, path = ...\nreturn { name = name, path = path }");
let lua = lua_with_roots(&[&root]);
let (name, path): (String, String) = lua
.load(r#"local m = require "who"; return m.name, m.path"#)
.eval_async()
.await
.unwrap();
assert_eq!(name, "who");
assert!(path.ends_with("who.lua"), "{path}");
}
#[tokio::test]
async fn test_require_rejects_malformed_names() {
let root = TempRoot::new("names");
let lua = lua_with_roots(&[&root]);
for name in ["", ".", "a..b", "../x", "a/b", "/etc/passwd", "a.b."] {
let err = lua
.load(format!("require {name:?}"))
.exec_async()
.await
.unwrap_err();
assert!(
err.to_string().contains("invalid module name"),
"{name}: {err}"
);
}
}
#[tokio::test]
async fn test_require_not_found_lists_candidates() {
let root = TempRoot::new("notfound");
let lua = lua_with_roots(&[&root]);
let err = lua.load(r#"require "nope""#).exec_async().await.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("module 'nope' not found"), "{msg}");
assert!(msg.contains("nope.lua"), "{msg}");
assert!(msg.contains("init.lua"), "{msg}");
}
#[tokio::test]
async fn test_require_circular_errors() {
let root = TempRoot::new("circular");
root.write("aaa.lua", "return require 'bbb'");
root.write("bbb.lua", "return require 'aaa'");
let lua = lua_with_roots(&[&root]);
let err = lua.load(r#"require "aaa""#).exec_async().await.unwrap_err();
assert!(err.to_string().contains("circular require"), "{err}");
}
#[tokio::test]
async fn test_require_nil_result_stored_as_true() {
let root = TempRoot::new("nilret");
root.write("sideeffect.lua", "MARK = 1");
let lua = lua_with_roots(&[&root]);
let (ret, again, mark): (bool, bool, i64) = lua
.load(r#"return require "sideeffect", require "sideeffect", MARK"#)
.eval_async()
.await
.unwrap();
assert!(ret, "a module returning nothing must yield true");
assert!(again, "...also on the cached path");
assert_eq!(mark, 1);
}
#[tokio::test]
async fn test_require_builtins_return_the_globals() {
let root = TempRoot::new("builtins");
let lua = lua_with_roots(&[&root]);
let all_same: bool = lua
.load(
r#"
for _, name in ipairs({"utils", "log", "sqlite", "http", "task", "math", "table", "string", "os"}) do
if not rawequal(require(name), _G[name]) then return false end
end
return true
"#,
)
.eval_async()
.await
.unwrap();
assert!(all_same, "require of a built-in must return its global table");
}
#[tokio::test]
async fn test_require_first_root_wins() {
let first = TempRoot::new("order1");
let second = TempRoot::new("order2");
first.write("dup.lua", "return 'first'");
second.write("dup.lua", "return 'second'");
let lua = lua_with_roots(&[&first, &second]);
let got: String = lua.load(r#"return require "dup""#).eval_async().await.unwrap();
assert_eq!(got, "first");
}
#[cfg(unix)]
#[tokio::test]
async fn test_require_symlink_resolves_to_same_module() {
let root = TempRoot::new("symlink");
root.write("orig.lua", "COUNT = (COUNT or 0) + 1\nreturn {}");
std::os::unix::fs::symlink(root.0.join("orig.lua"), root.0.join("alias.lua")).unwrap();
let lua = lua_with_roots(&[&root]);
let (same, count): (bool, i64) = lua
.load(r#"return rawequal(require "orig", require "alias"), COUNT"#)
.eval_async()
.await
.unwrap();
assert!(same, "symlinked names must resolve to one module instance");
assert_eq!(count, 1);
}
#[tokio::test]
async fn test_require_module_may_use_async_stdlib() {
let root = TempRoot::new("async");
root.write("sleepy.lua", "os.sleep(0.01)\nreturn 'awake'");
let lua = lua_with_roots(&[&root]);
let got: String = lua.load(r#"return require "sleepy""#).eval_async().await.unwrap();
assert_eq!(got, "awake");
}
#[tokio::test]
async fn test_require_module_error_propagates_and_is_not_cached() {
let root = TempRoot::new("boom");
root.write("boom.lua", "error('kaboom')");
let lua = lua_with_roots(&[&root]);
for _ in 0..2 {
// The second attempt must re-run the module (and fail the same way),
// not report a bogus circular require or return a cached value.
let err = lua.load(r#"require "boom""#).exec_async().await.unwrap_err();
assert!(err.to_string().contains("kaboom"), "{err}");
}
}
#[tokio::test]
async fn test_require_module_with_shebang_and_bom() {
let root = TempRoot::new("shebang");
root.write("sh.lua", "\u{FEFF}#!/usr/bin/env luna\nreturn 7");
let lua = lua_with_roots(&[&root]);
let got: i64 = lua.load(r#"return require "sh""#).eval_async().await.unwrap();
assert_eq!(got, 7);
}

File diff suppressed because it is too large Load Diff

@ -1,509 +0,0 @@
//! Tests for the tui module: `tui.styled` behavior and the prompt argument
//! validation that fires *before* the terminal is touched (so it's
//! deterministic in CI, tty or not). The interactive halves of the prompts
//! are exercised manually — see docs/tui.md.
use super::lua;
// ---------------------------------------------------------------------------
// module surface
// ---------------------------------------------------------------------------
#[test]
fn test_tui_global_has_all_functions() {
let lua = lua();
for f in [
"confirm",
"promptText",
"promptLongText",
"promptSelect",
"promptSecret",
"promptNumber",
"promptInt",
"promptDate",
"styled",
"escape",
"block",
"success",
"error",
"warning",
"caution",
"info",
"note",
"comment",
"outlineBlock",
"outlineSuccess",
"outlineError",
"outlineWarning",
"outlineNote",
"outlineInfo",
"outlineCaution",
"raw",
"screenInfo",
"addPreset",
"moveTo",
"move",
"saveCursor",
"restoreCursor",
"cursorPos",
"showCursor",
"cursorStyle",
"clearLine",
"clearScreen",
"altScreen",
"scrollRegion",
"scroll",
"setTitle",
"setClipboard",
"reset",
] {
let is_fn: bool = lua
.load(format!("return type(tui.{f}) == 'function'"))
.eval()
.unwrap();
assert!(is_fn, "tui.{f} is missing or not a function");
}
}
#[test]
fn test_require_returns_the_global() {
let lua = lua();
// require needs roots installed; builtins resolve without touching the fs.
crate::stdlib::require::install(&lua, vec![]).unwrap();
let same: bool = lua.load(r#"return require("tui") == tui"#).eval().unwrap();
assert!(same);
}
// ---------------------------------------------------------------------------
// tui.styled
// ---------------------------------------------------------------------------
// NOTE: whether escape codes are emitted depends on the test process's
// stdout (colored's tty/NO_COLOR detection), so these assertions only check
// properties that hold in both color modes. Exact byte output is pinned by
// the unit tests in stdlib/tui/markup.rs, which bypass the global detection.
#[test]
fn test_styled_returns_payload_text() {
let lua = lua();
let out: String = lua
.load(r#"return tui.styled("a <info>b</info> <fg=red;options=bold>c</> d")"#)
.eval()
.unwrap();
// Tags are consumed, payload text survives in order.
assert!(!out.contains("<info>"), "tag not consumed: {out:?}");
assert!(!out.contains("fg=red"), "tag not consumed: {out:?}");
let stripped: String = out.chars().filter(|c| " abcd".contains(*c)).collect();
assert_eq!(stripped, "a b c d");
}
#[test]
fn test_styled_resolves_escapes_and_keeps_invalid_tags() {
let lua = lua();
let out: String = lua
.load(r#"return tui.styled("\\<info>x <notatag> y")"#)
.eval()
.unwrap();
assert!(out.contains("<info>x"), "escape not resolved: {out:?}");
assert!(out.contains("<notatag>"), "invalid tag should stay literal: {out:?}");
}
#[test]
fn test_styled_shorthand_and_html_tags_are_consumed() {
let lua = lua();
let out: String = lua
.load(r#"return tui.styled("<red;on-white;bold>a</> <b>b</b> <i>c</i> <u>d</u> <s>e</s>")"#)
.eval()
.unwrap();
assert!(!out.contains('<'), "tags not consumed: {out:?}");
let stripped: String = out.chars().filter(|c| " abcde".contains(*c)).collect();
assert_eq!(stripped, "a b c d e");
}
#[test]
fn test_add_preset() {
let lua = lua();
// A registered preset works as a styled tag and as a block style.
let out: String = lua
.load(
r#"
tui.addPreset("keyword", "fg=white;bg=magenta")
tui.addPreset("kw2", "keyword") -- specs may reference other presets
return tui.styled("<keyword>foo</keyword> <kw2>bar</>")
"#,
)
.eval()
.unwrap();
assert!(!out.contains("<keyword>"), "preset tag not consumed: {out:?}");
assert!(out.contains("foo") && out.contains("bar"));
lua.load(r#"tui.block("x", {style = "keyword"})"#).exec().unwrap();
// Presets are per Lua state: a fresh state doesn't see "keyword".
let other = super::lua();
let out: String = other.load(r#"return tui.styled("<keyword>foo</keyword>")"#).eval().unwrap();
assert!(out.contains("<keyword>"), "preset leaked across states: {out:?}");
}
#[test]
fn test_add_preset_validation() {
let lua = lua();
for (src, expect) in [
("tui.addPreset(1, 'fg=red')", "tui.addPreset: name must be a string (got integer)"),
("tui.addPreset('2fast', 'fg=red')", "tui.addPreset: name must start with a letter"),
("tui.addPreset('a=b', 'fg=red')", "tui.addPreset: name must start with a letter"),
("tui.addPreset('kw', 'fg=notacolor')", "tui.addPreset: invalid style 'fg=notacolor'"),
("tui.addPreset('kw', 42)", "tui.addPreset: style must be a string (got integer)"),
] {
let err = lua.load(src).exec().unwrap_err();
let msg = err.to_string();
assert!(msg.contains(expect), "{src}\n expected {expect:?} in: {msg}");
}
}
#[test]
fn test_escape_makes_text_literal() {
let lua = lua();
// Exact escaping and round-trip bytes are pinned in stdlib/tui/markup.rs;
// here: escaped input stays literal, surrounding markup still works.
let out: String = lua
.load(r#"return tui.styled(tui.escape("<error>boom</error>\\") .. "<b>y</b>")"#)
.eval()
.unwrap();
assert!(out.contains("<error>boom</error>\\"), "escaped text should stay literal: {out:?}");
assert!(!out.contains("<b>"), "markup after escaped text should still work: {out:?}");
}
#[test]
fn test_escape_rejects_non_string() {
let lua = lua();
let err = lua.load(r#"return tui.escape(42)"#).eval::<String>().unwrap_err();
assert!(err.to_string().contains("tui.escape: expected a string (got integer)"), "{err}");
}
#[test]
fn test_styled_rejects_non_string() {
let lua = lua();
let err = lua.load(r#"return tui.styled(42)"#).eval::<String>().unwrap_err();
assert!(err.to_string().contains("tui.styled:"), "{err}");
}
#[test]
fn test_styled_action_tags_are_consumed() {
let lua = lua();
// Valid action tags are consumed in both color modes (bytes are pinned in
// stdlib/tui/markup.rs); invalid ones stay literal.
let out: String = lua
.load(r#"return tui.styled("a<up=3><clear=line>b <notanop=1> c")"#)
.eval()
.unwrap();
assert!(!out.contains("up=3") && !out.contains("clear"), "{out:?}");
assert!(out.contains("<notanop=1>"), "invalid tag should stay literal: {out:?}");
}
// ---------------------------------------------------------------------------
// prompt argument validation (fires before any terminal interaction)
// ---------------------------------------------------------------------------
/// Run a prompt call and assert it fails with a message carrying `expect`.
async fn assert_prompt_error(src: &str, expect: &str) {
let lua = lua();
let err = lua.load(src).exec_async().await.unwrap_err();
let msg = err.to_string();
assert!(msg.contains(expect), "{src}\n expected {expect:?} in: {msg}");
}
#[tokio::test]
async fn test_prompt_text_arg_validation() {
assert_prompt_error("tui.confirm(42)", "tui.confirm: prompt text must be a string (got integer)")
.await;
assert_prompt_error("tui.confirm('q', 'yes')", "tui.confirm: default must be a boolean").await;
assert_prompt_error(
"tui.promptText('q', 42)",
"tui.promptText: default must be a string (got integer)",
)
.await;
}
#[tokio::test]
async fn test_unknown_and_mistyped_options() {
assert_prompt_error(
"tui.promptText('q', nil, {placholder='x'})",
"tui.promptText: unknown option 'placholder' (allowed: ",
)
.await;
assert_prompt_error(
"tui.confirm('q', nil, {help=42})",
"tui.confirm: option 'help' must be a string (got integer)",
)
.await;
assert_prompt_error(
"tui.promptText('q', nil, {maxLen='five'})",
"tui.promptText: option 'maxLen' must be a non-negative integer",
)
.await;
assert_prompt_error(
"tui.promptText('q', nil, {minLen=5, maxLen=2})",
"tui.promptText: min (5) must not be greater than max (2)",
)
.await;
}
#[tokio::test]
async fn test_select_validation() {
assert_prompt_error(
"tui.promptSelect('q')",
"tui.promptSelect: opts table with 'options' is required",
)
.await;
assert_prompt_error(
"tui.promptSelect('q', nil, {})",
"tui.promptSelect: opts table with 'options' is required",
)
.await;
assert_prompt_error(
"tui.promptSelect('q', nil, {options={}})",
"tui.promptSelect: options must not be empty",
)
.await;
assert_prompt_error(
"tui.promptSelect('q', nil, {options={'a', {}}})",
"tui.promptSelect: options[2] must be a string (got table)",
)
.await;
assert_prompt_error(
"tui.promptSelect('q', 'nope', {options={'a','b'}})",
"tui.promptSelect: default 'nope' is not one of the options",
)
.await;
assert_prompt_error(
"tui.promptSelect('q', {'a'}, {options={'a','b'}})",
"tui.promptSelect: default must be a string (got table)",
)
.await;
assert_prompt_error(
"tui.promptSelect('q', {'a','nope'}, {options={'a','b'}, multiple=true})",
"tui.promptSelect: default 'nope' is not one of the options",
)
.await;
assert_prompt_error(
"tui.promptSelect('q', nil, {options={'a'}, minSelected=1})",
"tui.promptSelect: option 'minSelected' requires multiple = true",
)
.await;
}
#[tokio::test]
async fn test_password_and_number_validation() {
assert_prompt_error(
"tui.promptSecret('q', {display='partial'})",
"tui.promptSecret: option 'display' must be one of 'masked', 'hidden', 'full' (got 'partial')",
)
.await;
assert_prompt_error(
"tui.promptNumber('q', 'five')",
"tui.promptNumber: default must be a number (got string)",
)
.await;
assert_prompt_error(
"tui.promptNumber('q', nil, {min=10, max=1})",
"tui.promptNumber: min (10) must not be greater than max (1)",
)
.await;
assert_prompt_error(
"tui.promptInt('q', 1.5)",
"tui.promptInt: default must be an integer (got 1.5)",
)
.await;
assert_prompt_error(
"tui.promptInt('q', nil, {min=1.5})",
"tui.promptInt: option 'min' must be an integer (got 1.5)",
)
.await;
}
#[tokio::test]
async fn test_date_validation() {
assert_prompt_error(
"tui.promptDate('q', 'tomorrow')",
"tui.promptDate: default must be a YYYY-MM-DD string (got 'tomorrow')",
)
.await;
assert_prompt_error(
"tui.promptDate('q', nil, {min='2026-02-30'})",
"tui.promptDate: option 'min' must be a YYYY-MM-DD string",
)
.await;
assert_prompt_error(
"tui.promptDate('q', nil, {min='2026-06-01', max='2026-01-01'})",
"tui.promptDate: min (2026-06-01) must not be greater than max (2026-01-01)",
)
.await;
assert_prompt_error(
"tui.promptDate('q', nil, {weekStart='someday'})",
"tui.promptDate: option 'weekStart' must be a weekday name like 'monday' (got 'someday')",
)
.await;
}
// ---------------------------------------------------------------------------
// blocks / raw / screenInfo
// ---------------------------------------------------------------------------
// The block functions print to stdout; their rendering is pinned by unit
// tests in stdlib/tui/blocks.rs. Here we exercise argument validation and
// the informational surface.
#[test]
fn test_block_validation() {
let lua = lua();
for (src, expect) in [
("tui.success(42)", "tui.success: message must be a string or an array of strings (got integer)"),
("tui.block({})", "tui.block: message must not be empty"),
("tui.block({'a', 1})", "tui.block: message[2] must be a string (got integer)"),
("tui.block('x', {style='notastyle'})", "tui.block: invalid style 'notastyle'"),
("tui.outlineBlock('x', {label='X'})", "tui.outlineBlock: unknown option 'label'"),
("tui.note('x', {title='X'})", "tui.note: unknown option 'title'"),
] {
let err = lua.load(src).exec().unwrap_err();
let msg = err.to_string();
assert!(msg.contains(expect), "{src}\n expected {expect:?} in: {msg}");
}
}
#[test]
fn test_block_functions_print_without_error() {
let lua = lua();
// Not asserting on stdout (captured by the test harness); this pins that
// valid calls succeed for both shapes and with option overrides.
lua.load(
r#"
tui.success("all good")
tui.block({"one", "two"}, {label="HI", style="fg=blue", prefix="> ", padding=true})
tui.outlineError("boom")
tui.outlineBlock("custom", {title="Box", style="gray;bold", padding=false})
tui.raw("progress ", 42, "\r")
"#,
)
.exec()
.unwrap();
}
#[test]
fn test_screen_info_shape() {
let lua = lua();
let (tty, colors, width_type): (bool, bool, String) = lua
.load(
r#"
local i = tui.screenInfo()
return i.tty, i.colors, type(i.width)
"#,
)
.eval()
.unwrap();
// Under cargo test stdout is not a terminal; just pin the field types.
let _ = (tty, colors);
assert!(width_type == "number" || width_type == "nil", "width: {width_type}");
}
// ---------------------------------------------------------------------------
// terminal control (tui.moveTo & co.)
// ---------------------------------------------------------------------------
// Argument validation fires before the tty check, so these are deterministic
// under the test harness. The emitted bytes are pinned by the unit tests in
// stdlib/tui/term.rs and markup.rs.
#[test]
fn test_term_control_validation() {
let lua = lua();
for (src, expect) in [
("tui.moveTo()", "tui.moveTo: row and col cannot both be nil"),
("tui.moveTo(0, 1)", "tui.moveTo: row must be a positive integer (got 0)"),
("tui.moveTo(nil, 'x')", "tui.moveTo: col must be a positive integer (got string)"),
("tui.move('x')", "tui.move: dx must be an integer (got string)"),
("tui.move(1, 2.5)", "tui.move: dy must be an integer (got 2.5)"),
("tui.clearLine(2)", "tui.clearLine: mode must be -1 (to start), 0 (whole, default) or 1 (to end), got 2"),
("tui.clearScreen(1.5)", "tui.clearScreen: mode must be"),
("tui.altScreen()", "tui.altScreen: expected a boolean (got nil)"),
("tui.altScreen(1)", "tui.altScreen: expected a boolean (got integer)"),
("tui.showCursor('yes')", "tui.showCursor: expected a boolean or nil (got string)"),
("tui.cursorStyle('blocky')", "tui.cursorStyle: style must be 'block', 'underline' or 'bar' (got 'blocky')"),
("tui.cursorStyle('bar', {blnk=true})", "tui.cursorStyle: unknown option 'blnk'"),
("tui.cursorStyle(nil, {blink=true})", "tui.cursorStyle: a style is required when 'blink' is set"),
("tui.scrollRegion(5)", "tui.scrollRegion: top and bottom must both be given"),
("tui.scrollRegion(5, 2)", "tui.scrollRegion: top (5) must be less than bottom (2)"),
("tui.scrollRegion(0, 5)", "tui.scrollRegion: top must be a positive integer (got 0)"),
("tui.scroll()", "tui.scroll: expected an integer (got nil)"),
("tui.setTitle(42)", "tui.setTitle: title must be a string (got integer)"),
("tui.setTitle('a\\nb')", "tui.setTitle: title must not contain control characters"),
("tui.setClipboard(42)", "tui.setClipboard: text must be a string (got integer)"),
] {
let err = lua.load(src).exec().unwrap_err();
let msg = err.to_string();
assert!(msg.contains(expect), "{src}\n expected {expect:?} in: {msg}");
}
}
/// Off-tty every control function validates and then does nothing — no error,
/// no control bytes in piped output. Skipped when the harness's stdout is a
/// terminal (the calls would then really move the cursor around the runner).
#[test]
fn test_term_control_off_tty_is_noop() {
use std::io::IsTerminal;
if std::io::stdout().is_terminal() {
return;
}
let lua = lua();
lua.load(
r#"
tui.moveTo(1, 1); tui.moveTo(2); tui.moveTo(nil, 3)
tui.move(2, -1); tui.move(0, 0); tui.move()
tui.saveCursor(); tui.restoreCursor()
tui.showCursor(false); tui.showCursor()
tui.cursorStyle("bar", {blink=true}); tui.cursorStyle()
tui.clearLine(); tui.clearLine(-1); tui.clearLine(1)
tui.clearScreen(); tui.clearScreen(-1); tui.clearScreen(1)
tui.altScreen(true); tui.altScreen(false)
tui.scrollRegion(2, 10); tui.scrollRegion()
tui.scroll(3); tui.scroll(-2); tui.scroll(0)
tui.setTitle("test"); tui.setTitle("")
tui.setClipboard("clip")
tui.reset()
"#,
)
.exec()
.unwrap();
}
#[tokio::test]
async fn test_cursor_pos_off_tty_is_nil() {
use std::io::IsTerminal;
if std::io::stdout().is_terminal() {
return;
}
let lua = lua();
let (row, col): (Option<u32>, Option<u32>) =
lua.load("return tui.cursorPos()").eval_async().await.unwrap();
assert_eq!((row, col), (None, None));
}
/// Without a terminal, an actual prompt attempt must fail — with the
/// function's error prefix, not a panic. The exact wording (NotTTY vs an IO
/// error) varies by environment, so only the prefix is asserted.
///
/// Skipped when `cargo test` has any way to reach a terminal: the prompt
/// would then really render (garbling the runner output with raw-mode
/// artifacts) and hang waiting for a key. That includes redirected runs in an
/// interactive shell — crossterm falls back to `/dev/tty` when stdin is not a
/// terminal. Only truly terminal-less environments (CI) exercise this.
#[tokio::test]
async fn test_prompting_off_tty_errors_with_prefix() {
use std::io::IsTerminal;
let has_tty = std::io::stdin().is_terminal()
|| std::io::stderr().is_terminal()
|| std::fs::File::open("/dev/tty").is_ok();
if has_tty {
return;
}
let lua = lua();
let err = lua.load("tui.confirm('proceed?')").exec_async().await.unwrap_err();
assert!(err.to_string().contains("tui.confirm:"), "{err}");
}

@ -1,813 +0,0 @@
//! Tests for the `utils` global: NULL sentinel, isNull, toJSON/fromJSON, dump
//! (Rust side) and try, tryn (Lua side, lua/stdlib/utils.lua).
//!
//! Ported from flowbox-rt fb_lua utils_tests. `utils.dump` is the flowbox Rust
//! implementation (src/stdlib/lua_dump.rs), so the dump tests match flowbox's.
use super::json_eq;
#[test]
fn test_dump() {
let lua = super::lua();
let dump = |code: &str| -> String {
lua.load(format!("return utils.dump({})", code))
.eval::<String>()
.unwrap()
};
// nil
assert_eq!(dump("nil"), "nil");
// booleans
assert_eq!(dump("true"), "true");
assert_eq!(dump("false"), "false");
// integers
assert_eq!(dump("1"), "1");
assert_eq!(dump("0"), "0");
assert_eq!(dump("-42"), "-42");
// floats - whole numbers show .0 (Lua 5.4 tostring)
assert_eq!(dump("1.0"), "1.0");
assert_eq!(dump("0.0"), "0.0");
assert_eq!(dump("-5.0"), "-5.0");
// floats - with fractional part
assert_eq!(dump("1.5"), "1.5");
assert_eq!(dump("-3.14159"), "-3.14159");
assert_eq!(dump("0.001"), "0.001");
// strings are quoted and escaped JSON-style
assert_eq!(dump("'hello'"), "\"hello\"");
assert_eq!(dump("''"), "\"\""); // empty
assert_eq!(dump("'with spaces'"), "\"with spaces\"");
assert_eq!(dump("'special\\nchars\\ttab'"), "\"special\\nchars\\ttab\"");
// empty table
assert_eq!(dump("{}"), "{}");
// simple table with string key
assert_eq!(dump("{foo=1}"), "{foo=1}");
assert_eq!(dump("{_foo=1}"), "{_foo=1}");
assert_eq!(dump("{_1=1}"), "{_1=1}");
// a string key that does not look like an identifier is bracketed and quoted
assert_eq!(dump("{[\"1\"]=1}"), "{[\"1\"]=1}");
// nested table
assert_eq!(dump("{foo={bar=1}}"), "{foo={bar=1}}");
// array-style table (numeric keys)
assert_eq!(dump("{10, 20, 30}"), "{10, 20, 30}");
// mixed keys - order may vary, so check by substrings
let dumped = dump("{foo=1, [999]='a'}");
assert!(dumped.contains("foo=1"), "got: {dumped}");
assert!(dumped.contains("[999]=\"a\""), "got: {dumped}");
// a table with both an array part and hash keys dumps both
assert_eq!(dump("{10, 20, foo='hash'}"), "{10, 20, foo=\"hash\"}");
// function
assert_eq!(dump("function() end"), "<Function>");
}
#[test]
fn test_dump_deep_nesting() {
let lua = super::lua();
// Recursion limit: tables at depth > 20 are rendered as <TRUNCATED>
// Build a table nested 25 levels deep - truncation happens at depth 21
let deep_result: String = lua
.load(
r#"
local t = {val="leaf"}
for i = 1, 25 do
t = {nested=t}
end
return utils.dump(t)
"#,
)
.eval()
.unwrap();
// 20 levels of {nested=, then the innermost table's value truncated at depth 21
let expected = format!("{}{{nested=<TRUNCATED>}}{}", "{nested=".repeat(20), "}".repeat(20));
assert_eq!(deep_result, expected);
// A table just within the limit still dumps fully
let shallow_result: String = lua
.load(
r#"
local t = {val="leaf"}
for i = 1, 5 do
t = {nested=t}
end
return utils.dump(t)
"#,
)
.eval()
.unwrap();
let expected = format!("{}{{val=\"leaf\"}}{}", "{nested=".repeat(5), "}".repeat(5));
assert_eq!(shallow_result, expected);
}
#[test]
fn test_dump_circular() {
let lua = super::lua();
// There is no cycle detection; the depth limit terminates the recursion,
// so a self-referencing table dumps 21 levels and then truncates
let result: String = lua
.load(
r#"
local t = {}
t.self = t
return utils.dump(t)
"#,
)
.eval()
.unwrap();
let expected = format!("{}<TRUNCATED>{}", "{self=".repeat(21), "}".repeat(21));
assert_eq!(result, expected);
// The same table appearing twice as a sibling dumps twice
let result: String = lua
.load(
r#"
local shared = {x = 1}
return utils.dump({shared, shared})
"#,
)
.eval()
.unwrap();
assert_eq!(result, "{{x=1}, {x=1}}");
}
#[test]
fn test_to_json() {
let lua = super::lua();
// Primitives
let result: String = lua.load(r#"return utils.toJSON(nil)"#).eval().unwrap();
assert_eq!(result, "null");
let result: String = lua.load(r#"return utils.toJSON(true)"#).eval().unwrap();
assert_eq!(result, "true");
let result: String = lua.load(r#"return utils.toJSON(false)"#).eval().unwrap();
assert_eq!(result, "false");
let result: String = lua.load(r#"return utils.toJSON(42)"#).eval().unwrap();
assert_eq!(result, "42");
let result: String = lua.load(r#"return utils.toJSON(3.14)"#).eval().unwrap();
assert_eq!(result, "3.14");
let result: String = lua.load(r#"return utils.toJSON("hello")"#).eval().unwrap();
assert_eq!(result, "\"hello\"");
// Empty table serializes as an empty object
let result: String = lua.load(r#"return utils.toJSON({})"#).eval().unwrap();
assert_eq!(result, "{}");
// Array-style table
let result: String = lua.load(r#"return utils.toJSON({1, 2, 3})"#).eval().unwrap();
assert_eq!(result, "[1,2,3]");
// Object-style table
let result: String = lua.load(r#"return utils.toJSON({a=1})"#).eval().unwrap();
assert_eq!(result, "{\"a\":1}");
// Nested structure
let result: String = lua
.load(r#"return utils.toJSON({nested={value=42}})"#)
.eval()
.unwrap();
assert_eq!(result, "{\"nested\":{\"value\":42}}");
}
#[test]
fn test_to_json_mixed_and_integer_keys() {
let lua = super::lua();
// A table with both a sequence part and hash keys is serialized as an
// object; integer keys become string keys
let result: String = lua
.load(r#"return utils.toJSON({1, 2, x = 3})"#)
.eval()
.unwrap();
assert!(json_eq(&result, r#"{"1":1,"2":2,"x":3}"#), "got: {result}");
// Non-sequential integer keys become string object keys
let result: String = lua
.load(r#"return utils.toJSON({a = 1, [10] = "x"})"#)
.eval()
.unwrap();
assert!(json_eq(&result, r#"{"a":1,"10":"x"}"#), "got: {result}");
}
#[test]
fn test_to_json_pretty() {
let lua = super::lua();
// Second argument enables pretty-printing
let result: String = lua.load(r#"return utils.toJSON({a=1}, true)"#).eval().unwrap();
assert_eq!(result, "{\n \"a\": 1\n}");
// Explicit false behaves like the default compact output
let result: String = lua.load(r#"return utils.toJSON({a=1}, false)"#).eval().unwrap();
assert_eq!(result, "{\"a\":1}");
}
#[test]
fn test_to_json_unserializable_errors() {
let lua = super::lua();
// Unserializable values must raise an error instead of silently returning "null"
let err = lua.load(r#"return utils.toJSON(function() end)"#).exec().unwrap_err();
assert!(err.to_string().contains("toJSON"), "got: {err}");
assert!(err.to_string().contains("function"), "got: {err}");
// A single bad value must not silently discard the whole structure
let err = lua
.load(r#"return utils.toJSON({x = 1, f = function() end})"#)
.exec()
.unwrap_err();
assert!(err.to_string().contains("toJSON"), "got: {err}");
// Self-referencing tables cannot be serialized (caught by the depth limit)
let err = lua
.load(
r#"
local t = {}
t.self = t
return utils.toJSON(t)
"#,
)
.exec()
.unwrap_err();
assert!(err.to_string().contains("toJSON"), "got: {err}");
assert!(err.to_string().contains("nesting too deep"), "got: {err}");
// NaN and Infinity have no JSON representation
let err = lua.load(r#"return utils.toJSON(0/0)"#).exec().unwrap_err();
assert!(err.to_string().contains("NaN"), "got: {err}");
let err = lua.load(r#"return utils.toJSON(math.huge)"#).exec().unwrap_err();
assert!(err.to_string().contains("Infinity"), "got: {err}");
// Only string and integer keys can become JSON object keys
let err = lua.load(r#"return utils.toJSON({[true] = 1})"#).exec().unwrap_err();
assert!(err.to_string().contains("object key"), "got: {err}");
let err = lua.load(r#"return utils.toJSON({[1.5] = 1})"#).exec().unwrap_err();
assert!(err.to_string().contains("object key"), "got: {err}");
// An integer key that stringifies onto an existing string key must error
// rather than silently dropping one of the two values.
let err = lua
.load(r#"return utils.toJSON({[1] = "int", ["1"] = "str"})"#)
.exec()
.unwrap_err();
assert!(err.to_string().contains("duplicate object key"), "got: {err}");
// A non-UTF-8 string value gets a clear prefixed error, not mlua's bare
// "error converting Lua string to &str".
let err = lua.load(r#"return utils.toJSON("\255bad")"#).exec().unwrap_err();
assert!(err.to_string().contains("utils.toJSON"), "got: {err}");
assert!(err.to_string().contains("UTF-8"), "got: {err}");
}
#[test]
fn test_null_sentinel() {
let lua = super::lua();
// utils.NULL exists and is distinct from nil
let result: bool = lua.load(r#"return utils.NULL ~= nil"#).eval().unwrap();
assert!(result);
// isNull identifies the sentinel and nothing else
let result: bool = lua.load(r#"return utils.isNull(utils.NULL)"#).eval().unwrap();
assert!(result);
let result: bool = lua
.load(
r#"
return utils.isNull(nil) or utils.isNull(0) or utils.isNull("")
or utils.isNull({}) or utils.isNull(false)
"#,
)
.eval()
.unwrap();
assert!(!result);
// JSON null deserializes to the sentinel - consistently at any nesting level
let result: bool = lua.load(r#"return utils.isNull(utils.fromJSON("null"))"#).eval().unwrap();
assert!(result);
let result: bool = lua
.load(
r#"
local obj = utils.fromJSON('{"a": null, "b": 1}')
return utils.isNull(obj.a) and obj.a ~= nil and obj.b == 1
"#,
)
.eval()
.unwrap();
assert!(result);
// Nulls in arrays preserve the array length
let result: bool = lua
.load(
r#"
local arr = utils.fromJSON('[1, null, 3]')
return #arr == 3 and utils.isNull(arr[2]) and arr[1] == 1 and arr[3] == 3
"#,
)
.eval()
.unwrap();
assert!(result);
// The sentinel serializes back to JSON null
let result: String = lua.load(r#"return utils.toJSON(utils.NULL)"#).eval().unwrap();
assert_eq!(result, "null");
let result: String = lua.load(r#"return utils.toJSON({a = utils.NULL})"#).eval().unwrap();
assert_eq!(result, "{\"a\":null}");
// dump renders the sentinel as NULL, bare and inside tables
// (other light userdata dumps as <LightUserData>)
let result: String = lua.load(r#"return utils.dump(utils.NULL)"#).eval().unwrap();
assert_eq!(result, "NULL");
let result: String = lua.load(r#"return utils.dump({a = utils.NULL})"#).eval().unwrap();
assert_eq!(result, "{a=NULL}");
let result: String = lua.load(r#"return utils.dump({1, utils.NULL, 3})"#).eval().unwrap();
assert_eq!(result, "{1, NULL, 3}");
}
/// utils.NULL is mlua's own null sentinel (Value::NULL, a null-pointer light
/// userdata), so nulls interoperate with mlua's serde layer in both directions.
#[test]
fn test_null_sentinel_matches_mlua() {
use mlua::LuaSerdeExt;
let lua = super::lua();
let null_val: mlua::Value = lua.load(r#"return utils.NULL"#).eval().unwrap();
assert_eq!(null_val, mlua::Value::NULL);
// a null produced by mlua (lua.null()) is recognized by utils.isNull
let is_null: bool = lua
.globals()
.get::<mlua::Table>("utils")
.unwrap()
.get::<mlua::Function>("isNull")
.unwrap()
.call(lua.null())
.unwrap();
assert!(is_null);
}
#[test]
fn test_utils_table_extensible() {
let lua = super::lua();
// utils is a plain global table just like math and table - scripts can extend it
let result: i64 = lua
.load(
r#"
utils.myHelper = function() return 41 + 1 end
return utils.myHelper()
"#,
)
.eval()
.unwrap();
assert_eq!(result, 42);
// and the built-in functions are of course still there
let result: bool = lua.load(r#"return type(utils.try) == "function""#).eval().unwrap();
assert!(result);
}
#[test]
fn test_try_tryn_arg_validation() {
let lua = super::lua();
// The callback must be a function, rejected with a clear message
let err = lua.load(r#"return utils.try(5)"#).exec().unwrap_err();
assert!(err.to_string().contains("not a function"), "got: {err}");
let err = lua.load(r#"return utils.try(nil)"#).exec().unwrap_err();
assert!(err.to_string().contains("not a function"), "got: {err}");
let err = lua.load(r#"return utils.tryn(2, 5)"#).exec().unwrap_err();
assert!(err.to_string().contains("not a function"), "got: {err}");
// expectedCount must be a non-negative integer
let err = lua.load(r#"return utils.tryn(2.5, function() end)"#).exec().unwrap_err();
assert!(err.to_string().contains("integer"), "got: {err}");
assert!(lua.load(r#"return utils.tryn(-1, function() end)"#).exec().is_err());
}
#[test]
fn test_try_error_values() {
let lua = super::lua();
// A thrown table is passed through as-is (pcall semantics), so scripts can
// attach structured data to errors
let result: bool = lua
.load(
r#"
local v, err = utils.try(function() error({code = 42}) end)
return v == nil and type(err) == "table" and err.code == 42
"#,
)
.eval()
.unwrap();
assert!(result);
// A nil error value still yields a non-nil err, so `if err` always detects failure
let result: bool = lua
.load(
r#"
local v, err = utils.try(function() error() end)
return err ~= nil
"#,
)
.eval()
.unwrap();
assert!(result);
let result: bool = lua
.load(
r#"
local a, err = utils.tryn(1, function() error() end)
return err ~= nil
"#,
)
.eval()
.unwrap();
assert!(result);
}
#[test]
fn test_tryn_return_counts() {
let lua = super::lua();
// tryn returns exactly expectedCount + 1 values (the last one is the error slot),
// both on success and on failure
let result: i64 = lua
.load(r#"return select('#', utils.tryn(2, function() return 1 end))"#)
.eval()
.unwrap();
assert_eq!(result, 3);
let result: i64 = lua
.load(r#"return select('#', utils.tryn(2, function() error("x") end))"#)
.eval()
.unwrap();
assert_eq!(result, 3);
// expectedCount = 0 gives just the error slot
let result: bool = lua
.load(
r#"
local err = utils.tryn(0, function() return 1, 2 end)
return err == nil and select('#', utils.tryn(0, function() end)) == 1
"#,
)
.eval()
.unwrap();
assert!(result);
}
#[test]
fn test_tryn_count_cap() {
let lua = super::lua();
// Excessive expected_count must error early instead of allocating huge buffers
let err = lua
.load(r#"return utils.tryn(1000000, function() return 1 end)"#)
.exec()
.unwrap_err();
assert!(err.to_string().contains("at most"), "got: {err}");
let err = lua
.load(r#"return utils.tryn(65, function() return 1 end)"#)
.exec()
.unwrap_err();
assert!(err.to_string().contains("at most"), "got: {err}");
// The maximum allowed count still works
let result: i64 = lua
.load(
r#"
local r = {utils.tryn(64, function() return 42 end)}
return r[1]
"#,
)
.eval()
.unwrap();
assert_eq!(result, 42);
}
#[test]
fn test_from_json() {
let lua = super::lua();
// Primitives - JSON null becomes the utils.NULL sentinel, not nil
lua.load(r#"assert(utils.isNull(utils.fromJSON("null")))"#).exec().unwrap();
let result: bool = lua.load(r#"return utils.fromJSON("true")"#).eval().unwrap();
assert!(result);
let result: bool = lua.load(r#"return utils.fromJSON("false")"#).eval().unwrap();
assert!(!result);
let result: i64 = lua.load(r#"return utils.fromJSON("42")"#).eval().unwrap();
assert_eq!(result, 42);
// Whole JSON numbers arrive as Lua integers, fractional ones as floats
let result: bool = lua
.load(r#"return math.type(utils.fromJSON("42")) == "integer""#)
.eval()
.unwrap();
assert!(result);
let result: f64 = lua.load(r#"return utils.fromJSON("3.14")"#).eval().unwrap();
assert!((result - 3.14).abs() < 0.001);
let result: bool = lua
.load(r#"return math.type(utils.fromJSON("3.14")) == "float""#)
.eval()
.unwrap();
assert!(result);
let result: String = lua.load(r#"return utils.fromJSON('"hello"')"#).eval().unwrap();
assert_eq!(result, "hello");
// Array
let result: i64 = lua
.load(
r#"
local arr = utils.fromJSON("[1, 2, 3]")
return arr[1] + arr[2] + arr[3]
"#,
)
.eval()
.unwrap();
assert_eq!(result, 6);
// Object
let result: i64 = lua
.load(
r#"
local obj = utils.fromJSON('{"a": 10, "b": 20}')
return obj.a + obj.b
"#,
)
.eval()
.unwrap();
assert_eq!(result, 30);
// Nested structure
let result: i64 = lua
.load(
r#"
local data = utils.fromJSON('{"nested": {"value": 42}}')
return data.nested.value
"#,
)
.eval()
.unwrap();
assert_eq!(result, 42);
// Invalid JSON should error, and the message names the function
let err = lua.load(r#"return utils.fromJSON("not valid json")"#).exec().unwrap_err();
assert!(err.to_string().contains("fromJSON"), "got: {err}");
}
#[test]
fn test_json_roundtrip() {
let lua = super::lua();
// Round-trip test: Lua -> JSON -> Lua
let result: i64 = lua
.load(
r#"
local original = {x = 10, y = 20, items = {1, 2, 3}}
local json = utils.toJSON(original)
local restored = utils.fromJSON(json)
return restored.x + restored.y + restored.items[1]
"#,
)
.eval()
.unwrap();
assert_eq!(result, 31); // 10 + 20 + 1
// NULL survives a Lua -> JSON -> Lua round trip
let result: bool = lua
.load(
r#"
local restored = utils.fromJSON(utils.toJSON({a = utils.NULL}))
return utils.isNull(restored.a)
"#,
)
.eval()
.unwrap();
assert!(result);
}
#[test]
fn test_try() {
let lua = super::lua();
// Success case: returns value, nil error
let (value, has_error): (i64, bool) = lua
.load(
r#"
local val, err = utils.try(function() return 42 end)
return val, err ~= nil
"#,
)
.eval()
.unwrap();
assert_eq!(value, 42);
assert!(!has_error);
// Error case: returns nil value, error
let (is_nil, has_error): (bool, bool) = lua
.load(
r#"
local val, err = utils.try(function() error("boom") end)
return val == nil, err ~= nil
"#,
)
.eval()
.unwrap();
assert!(is_nil);
assert!(has_error);
// Error message is preserved
let contains_boom: bool = lua
.load(
r#"
local val, err = utils.try(function() error("boom") end)
return tostring(err):find("boom") ~= nil
"#,
)
.eval()
.unwrap();
assert!(contains_boom);
}
#[test]
fn test_try_shorthand() {
let lua = super::lua();
// pcall-style shorthand: the function and its arguments, no closure needed
let (value, has_error): (String, bool) = lua
.load(
r#"
local val, err = utils.try(string.rep, "ab", 3)
return val, err ~= nil
"#,
)
.eval()
.unwrap();
assert_eq!(value, "ababab");
assert!(!has_error);
// Errors thrown by the called function are captured the same way as with a closure
let captured: bool = lua
.load(
r#"
local val, err = utils.try(error, "boom")
return val == nil and tostring(err):find("boom") ~= nil
"#,
)
.eval()
.unwrap();
assert!(captured);
// nil arguments are forwarded with the argument count preserved (pcall semantics)
let result: i64 = lua
.load(
r#"
local function countArgs(...) return select('#', ...) end
local n = utils.try(countArgs, nil, nil, nil)
return n
"#,
)
.eval()
.unwrap();
assert_eq!(result, 3);
}
#[test]
fn test_tryn() {
let lua = super::lua();
// Success case with multiple return values
let (a, b, c, has_error): (i64, i64, i64, bool) = lua
.load(
r#"
local a, b, c, err = utils.tryn(3, function() return 1, 2, 3 end)
return a, b, c, err ~= nil
"#,
)
.eval()
.unwrap();
assert_eq!((a, b, c), (1, 2, 3));
assert!(!has_error);
// Error case: all values are nil, error is present
let (all_nil, has_error): (bool, bool) = lua
.load(
r#"
local a, b, c, err = utils.tryn(3, function() error("fail") end)
return a == nil and b == nil and c == nil, err ~= nil
"#,
)
.eval()
.unwrap();
assert!(all_nil);
assert!(has_error);
// Fewer return values than expected: pads with nil
let (a, b_nil, c_nil, has_error): (i64, bool, bool, bool) = lua
.load(
r#"
local a, b, c, err = utils.tryn(3, function() return 100 end)
return a, b == nil, c == nil, err ~= nil
"#,
)
.eval()
.unwrap();
assert_eq!(a, 100);
assert!(b_nil);
assert!(c_nil);
assert!(!has_error);
// More return values than expected: truncates
let (a, b, has_error): (i64, i64, bool) = lua
.load(
r#"
local a, b, err = utils.tryn(2, function() return 1, 2, 3, 4, 5 end)
return a, b, err ~= nil
"#,
)
.eval()
.unwrap();
assert_eq!((a, b), (1, 2));
assert!(!has_error);
}
#[test]
fn test_tryn_shorthand() {
let lua = super::lua();
// pcall-style shorthand: arguments after the callback are passed to it
let (q, r, has_error): (i64, i64, bool) = lua
.load(
r#"
local function divmod(a, b) return a // b, a % b end
local q, r, err = utils.tryn(2, divmod, 17, 5)
return q, r, err ~= nil
"#,
)
.eval()
.unwrap();
assert_eq!((q, r), (3, 2));
assert!(!has_error);
// Error case: arguments are passed, the error lands in the last slot
let (all_nil, captured): (bool, bool) = lua
.load(
r#"
local function fail(msg) error("failed: " .. msg) end
local a, b, err = utils.tryn(2, fail, "badly")
return a == nil and b == nil, tostring(err):find("failed: badly") ~= nil
"#,
)
.eval()
.unwrap();
assert!(all_nil);
assert!(captured);
// nil arguments are forwarded with the argument count preserved (pcall semantics)
let result: i64 = lua
.load(
r#"
local function countArgs(...) return select('#', ...) end
local n, err = utils.tryn(1, countArgs, nil, 5, nil)
return n
"#,
)
.eval()
.unwrap();
assert_eq!(result, 3);
}

@ -1,12 +1,7 @@
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use clap::Parser;
// Lua source embedded in test strings (e.g. `math.round(3.1415, 2)`) trips
// clippy's approx_constant lint, which mistakes the literals for `PI`.
#[cfg(test)]
#[allow(clippy::approx_constant)]
mod lua_tests;
mod repl;
mod sandbox;
mod stdlib;
@ -16,188 +11,38 @@ mod stdlib;
#[derive(Parser, Debug)]
#[command(version, about)]
struct Args {
/// Path to the Lua script to execute, followed by the script's arguments
/// (readable as os.args()). Omit the path to start an interactive REPL.
///
/// Everything after the script path belongs to the script — even tokens
/// spelled like the flags above — so luna's own flags must come first.
/// One positional per the interpreter convention (`python x.py -v` passes
/// -v to x.py); it also makes `#!/usr/bin/env luna` scripts receive their
/// command-line arguments. An optional `--` between the path and the
/// arguments is skipped.
#[arg(trailing_var_arg = true, allow_hyphen_values = true, value_name = "FILEPATH [ARGS]")]
script: Vec<std::ffi::OsString>,
/// Grant all filesystem access without prompting or recording anything.
#[arg(long, conflicts_with = "no_fs")]
allow_fs: bool,
/// Deny all filesystem access (fs module, sqlite databases on disk,
/// cookie-jar files).
#[arg(long)]
no_fs: bool,
/// Grant network access to every origin without prompting or recording
/// anything.
#[arg(long, conflicts_with = "no_net")]
allow_net: bool,
/// Deny all network access (http module).
#[arg(long)]
no_net: bool,
/// Deny filesystem access AND networking; overrides the --allow-* flags.
#[arg(long)]
sandbox: bool,
}
impl Args {
/// The permission policy the flags call for. Filesystem and network are
/// overridden independently; whatever is left interactive consults the
/// grants recorded for this script (keyed by its canonical path) in
/// access.json — the REPL passes `None` and never persists anything.
fn policy(&self, script_key: Option<String>) -> stdlib::perm::Policy {
use stdlib::perm::{store, Override, Policy};
let choose = |allow: bool, deny: bool, deny_flag: &'static str| {
if self.sandbox {
Some(Override::Deny { flag: "--sandbox" })
} else if deny {
Some(Override::Deny { flag: deny_flag })
} else if allow {
Some(Override::Allow)
} else {
None
}
};
let fs = choose(self.allow_fs, self.no_fs, "--no-fs");
let net = choose(self.allow_net, self.no_net, "--no-net");
// The store is only relevant while something can still prompt.
let (persisted, store_path) = if fs.is_none() || net.is_none() {
let store_path = store::default_store_path();
let persisted = match (&script_key, &store_path) {
(Some(key), Some(path)) => store::load_for_script(path, key),
_ => Default::default(),
};
(persisted, store_path)
} else {
(Default::default(), None)
};
Policy::new(fs, net, script_key, persisted, store_path)
}
/// Path to the Lua script to execute. Omit to start an interactive REPL.
filepath: Option<PathBuf>,
}
#[tokio::main]
async fn main() {
// `log` output is governed by $LUNA_LOG (and $LUNA_LOG_STYLE for color),
// not env_logger's default $RUST_LOG.
env_logger::Builder::from_env(
env_logger::Env::new().filter_or("LUNA_LOG", "warn").write_style("LUNA_LOG_STYLE"),
)
.init();
// A panicking runtime must not strand the user on the alternate screen or
// with a hidden cursor; restoring first also puts the panic message on
// the normal screen. Normal exits are covered below.
let default_panic = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
stdlib::tui::cleanup();
default_panic(info);
}));
async fn main() -> Result<(), Box<dyn std::error::Error>> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
let result = run().await;
// Restore any terminal state the script left behind (tui.altScreen & co.)
// before printing an error, so the message lands on the normal screen.
stdlib::tui::cleanup();
if let Err(err) = result {
// Display (not Debug) so a Lua error's traceback and multi-line messages
// read as intended instead of as one escaped line.
eprintln!("{err}");
std::process::exit(1);
}
}
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse();
// Split the trailing positionals into the script path and its arguments.
// clap only treats `--` as an escape before the first positional
// (`luna -- x.lua`); after the path it comes through literally, so a
// leading one is dropped here — `luna x.lua -- -v` and `luna x.lua -v`
// pass the same args. Later occurrences are the script's to interpret.
let (filepath, script_args) = match args.script.split_first() {
None => (None, &[] as &[std::ffi::OsString]),
Some((path, rest)) => {
let rest = match rest.split_first() {
Some((sep, rest)) if sep.as_os_str() == "--" => rest,
_ => rest,
};
(Some(PathBuf::from(path)), rest)
}
};
let lua = sandbox::create_sandboxed_lua()?;
// Exposed to scripts as os.args(). Empty in the REPL (any trailing
// argument would have been taken as the script path).
lua.set_app_data(stdlib::os_ext::ScriptArgs(script_args.to_vec()));
let Some(filepath) = filepath else {
// In the REPL, `.env` and modules are looked up from the CWD.
// Permissions are prompted for but never persisted (no script
// identity to record them under).
lua.set_app_data(std::sync::Arc::new(args.policy(None)));
setup_require(&lua, &std::env::current_dir()?)?;
let Some(filepath) = args.filepath else {
return repl::run(&lua).await;
};
// Resolve symlinks first: installed scripts are typically symlinks (e.g.
// from ~/.local/bin), and both `.env` and the script's libraries live next
// to the real file.
let realpath = tokio::fs::canonicalize(&filepath)
.await
.map_err(|e| format!("luna: cannot open {}: {e}", filepath.display()))?;
let script_dir = realpath.parent().unwrap_or(Path::new("/"));
setup_require(&lua, script_dir)?;
// Exposed to scripts as fs.getScriptDir() — the base for bundled assets.
lua.set_app_data(stdlib::fs::ScriptDir(script_dir.to_path_buf()));
// The canonical path doubles as the script's identity in the permission
// store; this replaces the session-only default planted by perm::install.
let script_key = realpath.to_string_lossy().into_owned();
lua.set_app_data(std::sync::Arc::new(args.policy(Some(script_key))));
let source = tokio::fs::read_to_string(&filepath).await?;
// Read as bytes, not a UTF-8 String: Lua source (and its string literals) are
// byte strings, and stock Lua runs files that aren't valid UTF-8.
let source = tokio::fs::read(&realpath)
.await
.map_err(|e| format!("luna: cannot read {}: {e}", filepath.display()))?;
// Skip a leading shebang line like stock Lua's luaL_loadfilex does (it
// skips any first line starting with '#'). The newline is kept so error
// messages still report correct line numbers.
let chunk = if source.starts_with('#') {
&source[source.find('\n').unwrap_or(source.len())..]
} else {
source.as_str()
};
lua.load(stdlib::require::prepare_chunk(&source))
lua.load(chunk)
// "@" marks the chunk name as a file path in error messages / tracebacks
.set_name(format!("@{}", realpath.display()))
.set_name(format!("@{}", filepath.display()))
.exec_async()
.await?;
Ok(())
}
/// Prepare module loading rooted at `dir` — the real directory of the main
/// script, or the CWD in the REPL. Loads `dir/.env` into the process
/// environment (variables already set in the real environment win), then
/// installs `require` searching `dir` followed by the `$LUNA_INCLUDE_PATHS`
/// entries (colon-separated, like `$PATH`).
fn setup_require(lua: &mlua::Lua, dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
match dotenvy::from_path(dir.join(".env")) {
Ok(()) => {}
Err(dotenvy::Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(format!("luna: cannot load {}: {e}", dir.join(".env").display()).into()),
}
let mut roots = vec![dir.to_path_buf()];
if let Some(paths) = std::env::var_os("LUNA_INCLUDE_PATHS") {
roots.extend(std::env::split_paths(&paths).filter(|p| !p.as_os_str().is_empty()));
}
stdlib::require::install(lua, roots)?;
Ok(())
}

@ -1,14 +1,16 @@
//! Interactive read-eval-print loop.
//!
//! Each line is compiled and run in exactly the same way scripts are run,
//! keeping the Lua state between lines.
//! Started when `a` is run with no script path. Each line is compiled and run in
//! the same sandboxed Lua state used for scripts, so the full stdlib (including
//! the async http/sqlite/os.sleep calls) is available. Evaluation uses the async
//! executor, matching how files are run.
use mlua::prelude::{LuaFunction, LuaMultiValue, LuaTable};
use mlua::Lua;
use rustyline::error::ReadlineError;
use rustyline::DefaultEditor;
const PROMPT: &str = ">>> ";
const PROMPT: &str = "a> ";
const CONTINUATION_PROMPT: &str = "..> ";
/// Outcome of trying to compile a piece of REPL input.
@ -69,7 +71,7 @@ pub(crate) async fn run(lua: &Lua) -> Result<(), Box<dyn std::error::Error>> {
let mut editor = DefaultEditor::new()?;
let history_path = std::env::var_os("HOME").map(|home| {
let mut p = std::path::PathBuf::from(home);
p.push(".luna_history");
p.push(".a_history");
p
});
if let Some(path) = &history_path {
@ -78,15 +80,12 @@ pub(crate) async fn run(lua: &Lua) -> Result<(), Box<dyn std::error::Error>> {
}
println!(
"luna {} — interactive Lua. Ctrl-D to exit.",
"a {} — interactive Lua. Ctrl-D to exit.",
env!("CARGO_PKG_VERSION")
);
// Accumulated lines of an as-yet-incomplete statement.
let mut buffer = String::new();
// Guards against an unbroken stream of read errors spinning forever (e.g. a
// reader that keeps failing without ever reaching EOF).
let mut consecutive_errors = 0u32;
loop {
let prompt = if buffer.is_empty() {
@ -97,7 +96,6 @@ pub(crate) async fn run(lua: &Lua) -> Result<(), Box<dyn std::error::Error>> {
match editor.readline(prompt) {
Ok(line) => {
consecutive_errors = 0;
if buffer.is_empty() {
buffer = line;
} else {
@ -129,24 +127,15 @@ pub(crate) async fn run(lua: &Lua) -> Result<(), Box<dyn std::error::Error>> {
// Ctrl-C: abandon the current input and start fresh.
Err(ReadlineError::Interrupted) => {
buffer.clear();
consecutive_errors = 0;
}
// Ctrl-D (on an empty line) or end of piped input: quit.
Err(ReadlineError::Eof) => break,
// Any other read error (e.g. a non-UTF-8 input line) is reported but
// does not end the session — a stray pasted byte shouldn't kill the
// REPL. Bail only if errors keep coming with no successful read in
// between, so a permanently broken stream can't spin forever.
Err(err) => {
eprintln!("{err}");
buffer.clear();
consecutive_errors += 1;
if consecutive_errors >= 100 {
break;
}
}
}
}
if let Some(path) = &history_path {
let _ = editor.save_history(path);

@ -1,258 +0,0 @@
//! `fs` — whole-file filesystem access behind the folder-based permission
//! system ([`super::perm`]).
//!
//! v1 keeps the API deliberately small: `fs.readAll` / `fs.writeAll` move a
//! whole file to/from a Lua string, `fs.list` names a directory's entries,
//! `fs.isDir` / `fs.isFile` are existence-and-type predicates, and
//! `fs.access(dir, "r"|"w")` runs the permission cycle explicitly.
//!
//! Every operation is checked against a per-script, per-directory grant:
//! recorded answers live in `access.json`, unknown ones are asked on the
//! terminal — `y`/`n` for this run, `A`/`N` recorded. Grants are recursive
//! (a granted parent covers all children) and keyed by the script's
//! canonical path; the REPL prompts the same way but never persists.
//! `--allow-fs`, `--no-fs` and `--sandbox` statically override the
//! interactive checks (see `main.rs`).
//!
//! `sqlite.connect` and http's cookie-jar files route through the same
//! checks via [`super::perm::ensure_dir_access`].
use std::path::{Path, PathBuf};
use mlua::prelude::{LuaResult, LuaString};
use mlua::Lua;
use super::perm::{self, Wants};
/// App-data slot for the main script's real (symlink-resolved) directory.
/// `main` plants it before running a script; absent in the REPL and in bare
/// test states, where `fs.getScriptDir()` returns nil.
pub(crate) struct ScriptDir(pub(crate) PathBuf);
/// `mlua::Error::external(format!(...))` — every Lua-facing error in this
/// module goes through this, prefixed `fs.<fn>: …`.
macro_rules! ext_err {
($($arg:tt)*) => { mlua::Error::external(format!($($arg)*)) };
}
pub(super) fn install(lua: &Lua) -> LuaResult<()> {
let fs = lua.create_table()?;
fs.set(
"readAll",
lua.create_async_function(|lua, path: LuaString| async move {
const F: &str = "fs.readAll";
let path = to_path(&path);
let (dir, target) = read_target(F, &path).await?;
perm::ensure_dir_access(&lua, F, dir, Wants::READ).await?;
let bytes = tokio::fs::read(&target)
.await
.map_err(|e| ext_err!("{F}: {}: {e}", path.display()))?;
lua.create_string(&bytes)
})?,
)?;
fs.set(
"writeAll",
lua.create_async_function(|lua, (path, content): (LuaString, LuaString)| async move {
const F: &str = "fs.writeAll";
let path = to_path(&path);
let (dir, target) = write_target(F, &path).await?;
perm::ensure_dir_access(&lua, F, dir, Wants::WRITE).await?;
tokio::fs::write(&target, &content.as_bytes())
.await
.map_err(|e| ext_err!("{F}: {}: {e}", path.display()))
})?,
)?;
fs.set(
"list",
lua.create_async_function(|lua, path: LuaString| async move {
const F: &str = "fs.list";
let path = to_path(&path);
let canon = canonicalize(F, &path).await?;
let meta = metadata(F, &path, &canon).await?;
if !meta.is_dir() {
return Err(ext_err!("{F}: {}: not a directory", path.display()));
}
perm::ensure_dir_access(&lua, F, canon.clone(), Wants::READ).await?;
let mut entries = tokio::fs::read_dir(&canon)
.await
.map_err(|e| ext_err!("{F}: {}: {e}", path.display()))?;
let mut names: Vec<Vec<u8>> = Vec::new();
while let Some(entry) = entries
.next_entry()
.await
.map_err(|e| ext_err!("{F}: {}: {e}", path.display()))?
{
use std::os::unix::ffi::OsStrExt;
names.push(entry.file_name().as_bytes().to_vec());
}
// read_dir order is arbitrary; sorted output is deterministic.
names.sort_unstable();
let list = lua.create_table_with_capacity(names.len(), 0)?;
for name in &names {
list.raw_push(lua.create_string(name)?)?;
}
Ok(list)
})?,
)?;
fs.set(
"isDir",
lua.create_async_function(|lua, path: LuaString| async move {
let path = to_path(&path);
let Ok(canon) = tokio::fs::canonicalize(&path).await else {
return Ok(false); // nonexistent: false without prompting
};
match tokio::fs::metadata(&canon).await {
Ok(meta) if meta.is_dir() => perm::check_dir_access(&lua, canon, Wants::READ).await,
_ => Ok(false),
}
})?,
)?;
fs.set(
"isFile",
lua.create_async_function(|lua, path: LuaString| async move {
let path = to_path(&path);
let Ok(canon) = tokio::fs::canonicalize(&path).await else {
return Ok(false);
};
match tokio::fs::metadata(&canon).await {
Ok(meta) if meta.is_file() => {
perm::check_dir_access(&lua, parent_dir(&canon), Wants::READ).await
}
_ => Ok(false),
}
})?,
)?;
fs.set(
"getWorkDir",
lua.create_function(|lua, ()| {
// Process state, not filesystem content — no permission check.
// This is what relative paths in the other fs functions (and in
// fs.access probes) resolve against.
use std::os::unix::ffi::OsStrExt;
let cwd = std::env::current_dir()
.map_err(|e| ext_err!("fs.getWorkDir: {e}"))?;
lua.create_string(cwd.as_os_str().as_bytes())
})?,
)?;
fs.set(
"getScriptDir",
lua.create_function(|lua, ()| {
// The real (symlink-resolved) directory of the main script, set
// by main before execution — the stable base for a script's
// bundled assets, independent of the CWD it was invoked from.
// Not permission-gated: it names a place, reads no content.
match lua.app_data_ref::<ScriptDir>() {
Some(dir) => {
use std::os::unix::ffi::OsStrExt;
Ok(Some(lua.create_string(dir.0.as_os_str().as_bytes())?))
}
None => Ok(None), // REPL: no script to have a directory
}
})?,
)?;
fs.set(
"access",
lua.create_async_function(|lua, (dir, mode): (LuaString, LuaString)| async move {
const F: &str = "fs.access";
let wants = match &*mode.as_bytes() {
b"r" => Wants::READ,
b"w" => Wants::WRITE,
_ => return Err(ext_err!("{F}: mode must be \"r\" or \"w\"")),
};
let dir = to_path(&dir);
let canon = canonicalize(F, &dir).await?;
let meta = metadata(F, &dir, &canon).await?;
if !meta.is_dir() {
return Err(ext_err!("{F}: {}: not a directory", dir.display()));
}
perm::check_dir_access(&lua, canon, wants).await
})?,
)?;
lua.globals().raw_set("fs", fs)?;
Ok(())
}
/// Lua strings are byte strings; on unix so are paths.
fn to_path(s: &LuaString) -> PathBuf {
use std::os::unix::ffi::OsStrExt;
PathBuf::from(std::ffi::OsStr::from_bytes(&s.as_bytes()))
}
/// The permission unit for operations on a file: its containing directory.
fn parent_dir(canon: &Path) -> PathBuf {
canon.parent().unwrap_or(Path::new("/")).to_path_buf()
}
async fn canonicalize(fname: &str, path: &Path) -> LuaResult<PathBuf> {
tokio::fs::canonicalize(path)
.await
.map_err(|e| ext_err!("{fname}: {}: {e}", path.display()))
}
async fn metadata(fname: &str, path: &Path, canon: &Path) -> LuaResult<std::fs::Metadata> {
tokio::fs::metadata(canon)
.await
.map_err(|e| ext_err!("{fname}: {}: {e}", path.display()))
}
/// Resolve an existing regular file into (canonical containing dir = the
/// permission unit, canonical file). Shared with http's cookie-jar load.
pub(super) async fn read_target(fname: &'static str, path: &Path) -> LuaResult<(PathBuf, PathBuf)> {
let canon = canonicalize(fname, path).await?;
let meta = metadata(fname, path, &canon).await?;
if !meta.is_file() {
return Err(ext_err!("{fname}: {}: not a regular file", path.display()));
}
Ok((parent_dir(&canon), canon))
}
/// Resolve a write destination that may not exist yet into (canonical
/// containing dir = the permission unit, write target). The containing
/// directory must exist — v1 has no mkdir-p. Shared with http's cookie-jar
/// save and sqlite's connect.
pub(super) async fn write_target(
fname: &'static str,
path: &Path,
) -> LuaResult<(PathBuf, PathBuf)> {
match tokio::fs::canonicalize(path).await {
Ok(canon) => {
let meta = metadata(fname, path, &canon).await?;
if meta.is_dir() {
return Err(ext_err!("{fname}: {}: is a directory", path.display()));
}
Ok((parent_dir(&canon), canon))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
// A dangling symlink would make write() land at a location the
// permission check never saw; refuse it.
if let Ok(meta) = tokio::fs::symlink_metadata(path).await
&& meta.file_type().is_symlink()
{
return Err(ext_err!("{fname}: {}: dangling symlink", path.display()));
}
let Some(name) = path.file_name() else {
return Err(ext_err!("{fname}: {}: not a file path", path.display()));
};
let parent = match path.parent() {
Some(p) if !p.as_os_str().is_empty() => p,
_ => Path::new("."),
};
let dir = tokio::fs::canonicalize(parent).await.map_err(|e| {
ext_err!("{fname}: {}: parent directory: {e}", path.display())
})?;
let target = dir.join(name);
Ok((dir, target))
}
Err(e) => Err(ext_err!("{fname}: {}: {e}", path.display())),
}
}

@ -1,80 +1,191 @@
use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, OnceLock};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use cookie_store::CookieStore;
use mlua::prelude::LuaResult;
use mlua::{Lua, Table as LuaTable, Value as LuaValue};
use reqwest_cookie_store::CookieStoreMutex;
use crate::stdlib::utils::lua_to_json;
const HTTP_LUA: &str = include_str!("../../lua/stdlib/http.lua");
const MAX_REDIRECTS: usize = 10;
// ---------------------------------------------------------------------------
// Cookie store
// Cookie jar
// ---------------------------------------------------------------------------
/// Mozilla's Public Suffix List, vendored so cookie domain checks work offline
/// (the same list curl and the browsers ship). Refresh with `make update-psl`.
fn public_suffix_list() -> &'static publicsuffix::List {
static PSL: OnceLock<publicsuffix::List> = OnceLock::new();
PSL.get_or_init(|| {
include_str!("../../assets/public_suffix_list.dat")
.parse()
.expect("vendored public suffix list must parse")
})
/// A minimal in-memory cookie jar: bare domain (no leading dot) → name → value.
/// Deliberately simple — it covers the >95% case of `Set-Cookie` flows without
/// pulling in the `cookie_store` crate, and serializes cleanly to JSONL.
#[derive(Default)]
struct CookieJar {
cookies: HashMap<String, HashMap<String, String>>,
}
/// An empty RFC 6265 cookie store with public-suffix checking, so a response
/// cannot plant a cookie scoped to a whole TLD (`Domain=com`) or to an
/// unrelated domain ("cookie tossing"). Domain, Path, Secure, HttpOnly,
/// Expires and Max-Age all behave per spec — this is `cookie_store`, the same
/// crate reqwest's own cookie support builds on.
fn new_cookie_store() -> CookieStore {
CookieStore::new_with_public_suffix(Some(public_suffix_list().clone()))
impl CookieJar {
/// All cookies (name, value) whose stored domain matches `host`: either an
/// exact match or `host` being a subdomain of the stored domain.
fn cookies_for(&self, host: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
for (domain, names) in &self.cookies {
let suffix = format!(".{domain}");
if host == domain || host.ends_with(&suffix) {
for (name, value) in names {
out.push((name.clone(), value.clone()));
}
}
}
out
}
/// Parse a single `Set-Cookie` header value and store the cookie. Extracts
/// `name=value` (first segment) and an optional `Domain=` attribute, falling
/// back to the request host. Malformed headers are ignored.
fn set_from_header(&mut self, host: &str, header: &str) {
let mut segments = header.split(';');
let first = match segments.next() {
Some(s) => s.trim(),
None => return,
};
let (name, value) = match first.split_once('=') {
Some((n, v)) => (n.trim(), v.trim()),
None => return,
};
if name.is_empty() {
return;
}
let mut domain = host.to_ascii_lowercase();
for seg in segments {
if let Some((k, v)) = seg.split_once('=')
&& k.trim().eq_ignore_ascii_case("domain")
{
let d = v.trim().trim_start_matches('.').to_ascii_lowercase();
if !d.is_empty() {
domain = d;
}
}
}
self.cookies
.entry(domain)
.or_default()
.insert(name.to_string(), value.to_string());
}
/// Serialize as JSONL — one `{"domain","name","value"}` object per line.
fn to_jsonl(&self) -> String {
let mut out = String::new();
for (domain, names) in &self.cookies {
for (name, value) in names {
let obj = serde_json::json!({
"domain": domain,
"name": name,
"value": value,
});
out.push_str(&obj.to_string());
out.push('\n');
}
}
out
}
/// Merge cookies from JSONL produced by `to_jsonl`. Bad lines are skipped.
fn merge_jsonl(&mut self, src: &str) {
for line in src.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
let domain = v.get("domain").and_then(|x| x.as_str());
let name = v.get("name").and_then(|x| x.as_str());
let value = v.get("value").and_then(|x| x.as_str());
if let (Some(d), Some(n), Some(val)) = (domain, name, value) {
self.cookies
.entry(d.to_string())
.or_default()
.insert(n.to_string(), val.to_string());
}
}
}
}
// ---------------------------------------------------------------------------
// Request options
// Request execution
// ---------------------------------------------------------------------------
/// `opts` parsed into owned values (the request may be built twice: once
/// plain, once more to answer a digest challenge).
#[derive(Default)]
struct RequestOpts {
timeout: Option<Duration>,
headers: Vec<(String, String)>,
cookies: Vec<(String, String)>,
/// Body bytes, with the Content-Type it implies (None for a raw body).
body: Option<(Vec<u8>, Option<&'static str>)>,
/// (is_digest, username, password).
auth: Option<(bool, String, String)>,
/// The caller set their own Content-Type header; it wins over the one a
/// json/form body would otherwise imply (no silent override, no duplicate).
has_user_content_type: bool,
/// `application/x-www-form-urlencoded` body from key/value pairs. Implemented
/// inline so the build needs no optional reqwest features.
fn form_urlencode(pairs: &[(String, String)]) -> String {
fn encode(s: &str) -> String {
let mut out = String::new();
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
b' ' => out.push('+'),
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
pairs
.iter()
.map(|(k, v)| format!("{}={}", encode(k), encode(v)))
.collect::<Vec<_>>()
.join("&")
}
fn parse_opts(opts: Option<&LuaTable>) -> LuaResult<RequestOpts> {
let mut o = RequestOpts::default();
let Some(opts) = opts else { return Ok(o) };
const MAX_REDIRECTS: u32 = 10;
/// Shared by the stateless `http.request` and a session's `:request`. When `jar`
/// is `Some`, the matching jar cookies are sent and any `Set-Cookie` responses
/// are stored back.
///
/// Redirects are followed manually (the client is built with
/// `redirect::Policy::none`) so that `Set-Cookie` headers on 30x responses —
/// the common login → redirect → dashboard pattern — are captured into the jar,
/// which reqwest's transparent redirect following would otherwise hide.
async fn execute_request(
lua: Lua,
client: reqwest::Client,
jar: Option<Arc<Mutex<CookieJar>>>,
method: String,
url: String,
opts: Option<LuaTable>,
) -> LuaResult<LuaTable> {
let mut method = reqwest::Method::from_bytes(method.to_ascii_uppercase().as_bytes())
.map_err(|e| mlua::Error::external(format!("http: invalid method '{method}': {e}")))?;
// Parse opts once into owned pieces so each redirect hop can rebuild the
// request (a reqwest RequestBuilder is single-use).
let mut timeout: Option<Duration> = None;
let mut custom_headers: Vec<(String, String)> = Vec::new();
let mut opts_cookies: Vec<(String, String)> = Vec::new();
// Body, with the Content-Type it implies (None for a raw body).
let mut body: Option<(Vec<u8>, Option<&'static str>)> = None;
// Auth: (is_digest, username, password).
let mut auth: Option<(bool, String, String)> = None;
if let Some(opts) = opts.as_ref() {
if let Some(t) = opts.get::<Option<f64>>("timeout")? {
o.timeout = Some(Duration::try_from_secs_f64(t).map_err(|_| {
mlua::Error::external("http: timeout must be a non-negative finite number of seconds")
timeout = Some(Duration::try_from_secs_f64(t).map_err(|_| {
mlua::Error::external(
"http: timeout must be a non-negative finite number of seconds",
)
})?);
}
if let Some(headers) = opts.get::<Option<LuaTable>>("headers")? {
for pair in headers.pairs::<String, String>() {
o.headers.push(pair?);
custom_headers.push(pair?);
}
}
if let Some(cookies) = opts.get::<Option<LuaTable>>("cookies")? {
for pair in cookies.pairs::<String, String>() {
o.cookies.push(pair?);
opts_cookies.push(pair?);
}
}
@ -82,19 +193,18 @@ fn parse_opts(opts: Option<&LuaTable>) -> LuaResult<RequestOpts> {
if let Some(json_val) = opts.get::<Option<LuaValue>>("json")? {
let json = lua_to_json(json_val, 0)?;
let s = serde_json::to_string(&json).map_err(mlua::Error::external)?;
o.body = Some((s.into_bytes(), Some("application/json")));
body = Some((s.into_bytes(), Some("application/json")));
} else if let Some(form) = opts.get::<Option<LuaTable>>("form")? {
let mut ser = form_urlencoded::Serializer::new(String::new());
let mut pairs = Vec::new();
for pair in form.pairs::<String, String>() {
let (k, v) = pair?;
ser.append_pair(&k, &v);
pairs.push(pair?);
}
o.body = Some((
ser.finish().into_bytes(),
body = Some((
form_urlencode(&pairs).into_bytes(),
Some("application/x-www-form-urlencoded"),
));
} else if let Some(b) = opts.get::<Option<mlua::String>>("body")? {
o.body = Some((b.as_bytes().to_vec(), None));
body = Some((b.as_bytes().to_vec(), None));
}
// Auth: { username, password, scheme = "basic" (default) | "digest" }.
@ -114,78 +224,46 @@ fn parse_opts(opts: Option<&LuaTable>) -> LuaResult<RequestOpts> {
)));
}
};
o.auth = Some((is_digest, username, password));
auth = Some((is_digest, username, password));
}
o.has_user_content_type = o
.headers
.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("content-type"));
// A Cookie header and opts.cookies would produce two Cookie headers (or
// silently drop one) — refuse the ambiguity instead.
if !o.cookies.is_empty() && o.headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("cookie")) {
return Err(mlua::Error::external(
"http: pass cookies either in opts.cookies or as a Cookie header, not both",
));
}
Ok(o)
}
let is_digest = matches!(auth.as_ref(), Some((true, _, _)));
let mut digest_header: Option<String> = None;
let mut digest_tried = false;
// ---------------------------------------------------------------------------
// Request execution
// ---------------------------------------------------------------------------
let mut url = url;
let mut redirects_left = MAX_REDIRECTS;
/// reqwest's Display often hides the interesting part ("too many redirects",
/// "operation timed out") behind a generic wrapper — surface the source chain.
fn req_error(e: reqwest::Error) -> mlua::Error {
use std::error::Error as _;
let mut msg = format!("http: {e}");
let mut src = e.source();
while let Some(s) = src {
msg.push_str(&format!(": {s}"));
src = s.source();
}
mlua::Error::external(msg)
}
let resp = loop {
// Host used for cookie matching and as the Set-Cookie domain fallback;
// recomputed each hop since a redirect may cross hosts.
let host = reqwest::Url::parse(&url)
.ok()
.and_then(|u| u.host_str().map(|h| h.to_ascii_lowercase()));
let mut req = client.request(method.clone(), &url);
/// Assemble one request. Redirects, cookie capture (including `Set-Cookie` on
/// 30x hops) and re-sending jar cookies to each hop are reqwest's job: the
/// session client carries the cookie store as its provider, and its redirect
/// policy strips Authorization/Cookie when a redirect leaves the host.
fn build_request(
client: &reqwest::Client,
jar: Option<&CookieStoreMutex>,
method: &reqwest::Method,
url: &reqwest::Url,
o: &RequestOpts,
digest_header: Option<&str>,
) -> reqwest::RequestBuilder {
let mut req = client.request(method.clone(), url.clone());
if let Some(t) = o.timeout {
if let Some(t) = timeout {
req = req.timeout(t);
}
for (k, v) in &o.headers {
for (k, v) in &custom_headers {
req = req.header(k, v);
}
// Per-request cookies. reqwest only injects jar cookies when the request
// has no Cookie header, so an explicit header must merge the jar's cookies
// itself (per-request wins on a name collision).
if !o.cookies.is_empty() {
let mut map: HashMap<String, String> = HashMap::new();
if let Some(jar) = jar {
let store = jar.lock().unwrap_or_else(|e| e.into_inner());
for (n, v) in store.get_request_values(url) {
map.insert(n.to_string(), v.to_string());
// Cookie header: jar cookies for this host, then per-request cookies
// which override on name collision.
let mut cookie_map: HashMap<String, String> = HashMap::new();
if let (Some(jar), Some(host)) = (jar.as_ref(), host.as_ref()) {
for (n, v) in jar.lock().unwrap().cookies_for(host) {
cookie_map.insert(n, v);
}
}
for (k, v) in &o.cookies {
map.insert(k.clone(), v.clone());
for (k, v) in &opts_cookies {
cookie_map.insert(k.clone(), v.clone());
}
let header = map
if !cookie_map.is_empty() {
let header = cookie_map
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
@ -193,193 +271,134 @@ fn build_request(
req = req.header(reqwest::header::COOKIE, header);
}
if let Some((bytes, implied_ct)) = &o.body {
if let Some(ct) = implied_ct
&& !o.has_user_content_type
{
if let Some((bytes, ct)) = body.as_ref() {
if let Some(ct) = ct {
req = req.header(reqwest::header::CONTENT_TYPE, *ct);
}
req = req.body(bytes.clone());
}
// Basic auth goes out with the request; digest's Authorization exists only
// after a 401 challenge has been answered (digest_header).
match (&o.auth, digest_header) {
(Some((false, username, password)), _) => req = req.basic_auth(username, Some(password)),
(Some((true, ..)), Some(h)) => req = req.header(reqwest::header::AUTHORIZATION, h),
_ => {}
// Auth. Basic goes out on every hop; digest's Authorization is set only
// after the 401 challenge below has been answered (digest_header).
if let Some((digest, username, password)) = auth.as_ref() {
if *digest {
if let Some(h) = digest_header.as_ref() {
req = req.header(reqwest::header::AUTHORIZATION, h);
}
} else {
req = req.basic_auth(username, Some(password));
}
}
req
}
let resp = req
.send()
.await
.map_err(|e| mlua::Error::external(format!("http: {e}")))?;
let status = resp.status();
/// Answer a digest challenge: the Authorization header value for retrying the
/// request that came back 401, and the URL to retry (the response's final URL,
/// so a challenge behind a redirect is answered for the right path).
fn digest_answer(
resp: &reqwest::Response,
method: &reqwest::Method,
o: &RequestOpts,
) -> Option<(reqwest::Url, String)> {
let (_, username, password) = o.auth.as_ref()?;
// Store Set-Cookie from this hop into the jar.
if let (Some(jar), Some(host)) = (jar.as_ref(), host.as_ref()) {
let mut j = jar.lock().unwrap();
for value in resp.headers().get_all(reqwest::header::SET_COOKIE).iter() {
if let Ok(s) = value.to_str() {
j.set_from_header(host, s);
}
}
}
// Digest auth: answer a 401 challenge once, then retry the same request
// with the computed Authorization header.
if is_digest && !digest_tried && status == reqwest::StatusCode::UNAUTHORIZED {
let challenge = resp
.headers()
.get(reqwest::header::WWW_AUTHENTICATE)?
.to_str()
.ok()?;
let mut prompt = digest_auth::parse(challenge).ok()?;
let url = resp.url().clone();
let uri = match url.query() {
Some(q) => format!("{}?{q}", url.path()),
None => url.path().to_string(),
};
.get(reqwest::header::WWW_AUTHENTICATE)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
if let Some((_, username, password)) = auth.as_ref()
&& let Some(challenge) = challenge
&& let Ok(mut prompt) = digest_auth::parse(&challenge)
{
let uri = reqwest::Url::parse(&url)
.map(|u| match u.query() {
Some(q) => format!("{}?{}", u.path(), q),
None => u.path().to_string(),
})
.unwrap_or_else(|_| url.clone());
let ctx = digest_auth::AuthContext::new_with_method(
username.as_str(),
password.as_str(),
uri,
o.body.as_ref().map(|(b, _)| b.as_slice()),
// If a 303 redirect degraded the method before the challenge, this
// replays the original method; a digest endpoint behind a
// method-changing redirect is not a case worth carrying state for.
body.as_ref().map(|(b, _)| b.as_slice()),
digest_auth::HttpMethod::from(method.as_str()),
);
let answer = prompt.respond(&ctx).ok()?;
Some((url, answer.to_header_string()))
}
/// Shared by the stateless `http.request` and a session's `:request`. A
/// session's cookie jar rides along inside `client` (as its cookie provider);
/// `jar` is passed separately only so per-request cookies can merge with it.
async fn execute_request(
lua: Lua,
client: reqwest::Client,
jar: Option<Arc<CookieStoreMutex>>,
method: String,
url: String,
opts: Option<LuaTable>,
) -> LuaResult<LuaTable> {
let method = reqwest::Method::from_bytes(method.to_ascii_uppercase().as_bytes())
.map_err(|e| mlua::Error::external(format!("http: invalid method '{method}': {e}")))?;
let url = reqwest::Url::parse(&url)
.map_err(|e| mlua::Error::external(format!("http: invalid URL '{url}': {e}")))?;
// Every request funnels through here (http.request, the shorthands, and
// all session methods) — the one choke point for the permission system.
// The unit is the URL's origin; a redirect that stays within reqwest's
// auto-follow is not re-checked (the client strips sensitive headers on
// cross-origin hops).
super::perm::ensure_origin_access(&lua, &origin_key(&url)?).await?;
let o = parse_opts(opts.as_ref())?;
let resp = build_request(&client, jar.as_deref(), &method, &url, &o, None)
.send()
.await
.map_err(req_error)?;
if let Ok(answer) = prompt.respond(&ctx) {
digest_header = Some(answer.to_header_string());
digest_tried = true;
continue;
}
}
}
// Digest auth: answer a 401 challenge once, then retry the same request
// with the computed Authorization header. (Each send has its own timeout
// window; the challenge round-trip is a second request.)
let resp = if matches!(o.auth, Some((true, ..)))
&& resp.status() == reqwest::StatusCode::UNAUTHORIZED
&& let Some((retry_url, header)) = digest_answer(&resp, &method, &o)
// Follow a redirect if there is one to follow.
if status.is_redirection()
&& redirects_left > 0
&& let Some(next) = resp
.headers()
.get(reqwest::header::LOCATION)
.and_then(|l| l.to_str().ok())
.and_then(|loc| reqwest::Url::parse(&url).and_then(|base| base.join(loc)).ok())
{
build_request(&client, jar.as_deref(), &method, &retry_url, &o, Some(&header))
.send()
.await
.map_err(req_error)?
} else {
resp
redirects_left -= 1;
// 303, and 301/302 on a POST, degrade to a bodyless GET — the
// behaviour browsers and reqwest's own redirect policy apply.
let code = status.as_u16();
if code == 303 || ((code == 301 || code == 302) && method == reqwest::Method::POST) {
method = reqwest::Method::GET;
body = None;
}
url = next.to_string();
continue;
}
break resp;
};
let status = resp.status().as_u16();
// Response headers, keyed by lowercase name. Repeats are joined with ", "
// (RFC 9110 list semantics) — except Set-Cookie, whose values may contain
// commas; the headers table keeps its first value and the full list is
// exposed as resp.setCookies.
// Response headers: lowercase names, first value wins.
let headers_tbl = lua.create_table()?;
let set_cookies = lua.create_table()?;
for name in resp.headers().keys() {
for (name, value) in resp.headers().iter() {
let lname = name.as_str().to_ascii_lowercase();
let mut values = resp.headers().get_all(name).iter();
if name == reqwest::header::SET_COOKIE {
if let Some(first) = values.next() {
headers_tbl.raw_set(lname.as_str(), lua.create_string(first.as_bytes())?)?;
}
for v in resp.headers().get_all(name) {
set_cookies.raw_push(lua.create_string(v.as_bytes())?)?;
}
} else {
let mut joined: Vec<u8> = Vec::new();
for (i, v) in values.enumerate() {
if i > 0 {
joined.extend_from_slice(b", ");
}
joined.extend_from_slice(v.as_bytes());
}
headers_tbl.raw_set(lname.as_str(), lua.create_string(&joined)?)?;
if !headers_tbl.contains_key(lname.as_str())? {
headers_tbl.raw_set(lname.as_str(), lua.create_string(value.as_bytes())?)?;
}
}
let final_url = resp.url().to_string();
let bytes = resp.bytes().await.map_err(req_error)?;
let bytes = resp
.bytes()
.await
.map_err(|e| mlua::Error::external(format!("http: {e}")))?;
let out = lua.create_table()?;
out.raw_set("status", status)?;
out.raw_set("ok", (200..=299).contains(&status))?;
out.raw_set("url", final_url)?;
out.raw_set("headers", headers_tbl)?;
out.raw_set("setCookies", set_cookies)?;
out.raw_set("body", lua.create_string(&bytes)?)?;
Ok(out)
}
/// The permission unit for a request: the URL's origin, `scheme://host` with
/// a `:port` suffix only when it isn't the scheme's default — e.g.
/// `https://example.com`, `http://insecure.example.com:8090`. Credentials
/// (basic auth in the URL), path, query and fragment are stripped; the host
/// comes back lowercased and punycoded.
fn origin_key(url: &reqwest::Url) -> LuaResult<String> {
if !matches!(url.scheme(), "http" | "https") {
return Err(mlua::Error::external(format!(
"http: unsupported URL scheme '{}'",
url.scheme()
)));
}
Ok(url.origin().ascii_serialization())
}
// ---------------------------------------------------------------------------
// Client construction
// ---------------------------------------------------------------------------
/// One client per cookie jar: reqwest attaches the store at build time. The
/// stateless module shares a single jarless client; each session builds its own.
fn build_client(jar: Option<Arc<CookieStoreMutex>>) -> reqwest::Result<reqwest::Client> {
let mut builder = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
// reqwest sends no User-Agent by default; some edges/CDNs reject
// empty-UA requests outright. A per-request `headers` entry overrides it.
.user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")))
.redirect(reqwest::redirect::Policy::limited(MAX_REDIRECTS));
if let Some(jar) = jar {
builder = builder.cookie_provider(jar);
}
builder.build()
}
// ---------------------------------------------------------------------------
// Session
// ---------------------------------------------------------------------------
/// Build the plain Lua table that backs a session. State (the cookie store)
/// lives in the closures; the Lua layer applies a metatable for the method
/// shorthands. Methods are called as `s:method(...)`, so each closure receives
/// the session table as a leading `_this` argument that it ignores.
/// Build the plain Lua table that backs a session. State (the cookie jar) lives
/// in the closures; the Lua layer applies a metatable for the method shorthands.
/// Methods are called as `s:method(...)`, so each closure receives the session
/// table as a leading `_this` argument that it ignores.
fn make_session(
lua: &Lua,
client: reqwest::Client,
jar: Arc<CookieStoreMutex>,
jar: Arc<Mutex<CookieJar>>,
) -> LuaResult<LuaTable> {
let tbl = lua.create_table()?;
@ -391,60 +410,36 @@ fn make_session(
move |lua, (_this, method, url, opts): (LuaTable, String, String, Option<LuaTable>)| {
let client = client.clone();
let jar = jar.clone();
async move { execute_request(lua, client, Some(jar), method, url, opts).await }
async move {
execute_request(lua, client, Some(jar), method, url, opts).await
}
}
})?,
)?;
// Cookie-jar files hold live credentials; both directions go through the
// fs permission system (async only because the permission cycle is).
tbl.raw_set("save", {
let jar = jar.clone();
lua.create_async_function(move |lua, (_this, path): (LuaTable, String)| {
let jar = jar.clone();
async move {
let (dir, target) =
super::fs::write_target("session:save", Path::new(&path)).await?;
super::perm::ensure_dir_access(&lua, "session:save", dir, super::perm::Wants::WRITE)
.await?;
// Session cookies (no Expires/Max-Age — most login tokens) are
// exactly what the caller wants to persist, so include them.
let mut buf = Vec::new();
cookie_store::serde::json::save_incl_expired_and_nonpersistent(
&jar.lock().unwrap_or_else(|e| e.into_inner()),
&mut buf,
)
.map_err(|e| mlua::Error::external(format!("session:save: {e}")))?;
tokio::fs::write(&target, buf)
.await
lua.create_function(move |_, (_this, path): (LuaTable, String)| {
let jsonl = jar.lock().unwrap().to_jsonl();
std::fs::write(&path, jsonl)
.map_err(|e| mlua::Error::external(format!("session:save: {e}")))
}
})?
})?;
tbl.raw_set("load", {
let jar = jar.clone();
lua.create_async_function(move |lua, (_this, path): (LuaTable, String)| {
let jar = jar.clone();
async move {
let (dir, target) =
super::fs::read_target("session:load", Path::new(&path)).await?;
super::perm::ensure_dir_access(&lua, "session:load", dir, super::perm::Wants::READ)
.await?;
let src = tokio::fs::read_to_string(&target)
.await
lua.create_function(move |_, (_this, path): (LuaTable, String)| {
let src = std::fs::read_to_string(&path)
.map_err(|e| mlua::Error::external(format!("session:load: {e}")))?;
let loaded = load_store("session:load", &src)?;
*jar.lock().unwrap_or_else(|e| e.into_inner()) = loaded;
jar.lock().unwrap().merge_jsonl(&src);
Ok(())
}
})?
})?;
tbl.raw_set("clearCookies", {
let jar = jar.clone();
lua.create_function(move |_, _this: LuaTable| {
jar.lock().unwrap_or_else(|e| e.into_inner()).clear();
jar.lock().unwrap().cookies.clear();
Ok(())
})?
})?;
@ -453,18 +448,13 @@ fn make_session(
let jar = jar.clone();
lua.create_function(move |lua, _this: LuaTable| {
let outer = lua.create_table()?;
let store = jar.lock().unwrap_or_else(|e| e.into_inner());
for c in store.iter_unexpired() {
let Some(domain) = c.domain.as_cow() else { continue };
let inner = match outer.raw_get::<Option<LuaTable>>(domain.as_ref())? {
Some(t) => t,
None => {
let t = lua.create_table()?;
outer.raw_set(domain.as_ref(), &t)?;
t
let j = jar.lock().unwrap();
for (domain, names) in j.cookies.iter() {
let inner = lua.create_table()?;
for (name, value) in names.iter() {
inner.raw_set(name.as_str(), value.as_str())?;
}
};
inner.raw_set(c.name(), c.value())?;
outer.raw_set(domain.as_str(), inner)?;
}
Ok(outer)
})?
@ -473,15 +463,6 @@ fn make_session(
Ok(tbl)
}
/// Parse a saved jar (JSONL, one cookie per line, `cookie_store`'s format),
/// re-attaching the public suffix list — a loaded store must reject bad
/// Domain attributes exactly like a fresh one.
fn load_store(fname: &str, src: &str) -> LuaResult<CookieStore> {
cookie_store::serde::json::load_all(src.as_bytes())
.map_err(|e| mlua::Error::external(format!("{fname}: malformed cookie jar: {e}")))
.map(|s| s.with_suffix_list(public_suffix_list().clone()))
}
// ---------------------------------------------------------------------------
// Installation
// ---------------------------------------------------------------------------
@ -494,7 +475,16 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> {
// first install in the process wins, and they would all install ring.
let _ = rustls::crypto::ring::default_provider().install_default();
let client = build_client(None).map_err(mlua::Error::external)?;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
// reqwest sends no User-Agent by default; some edges/CDNs reject
// empty-UA requests outright. A per-request `headers` entry overrides it.
.user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")))
// Redirects are followed manually in execute_request so Set-Cookie
// headers on 30x responses can be captured into the cookie jar.
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(mlua::Error::external)?;
let http = lua.create_table()?;
@ -513,21 +503,21 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> {
// http.session(path?) — constructor returning the raw session table.
http.raw_set(
"session",
lua.create_async_function(move |lua, path: Option<String>| async move {
let mut store = new_cookie_store();
lua.create_async_function({
let client = client.clone();
move |lua, path: Option<String>| {
let client = client.clone();
async move {
let mut jar = CookieJar::default();
if let Some(p) = path {
let (dir, target) =
super::fs::read_target("http.session", Path::new(&p)).await?;
super::perm::ensure_dir_access(&lua, "http.session", dir, super::perm::Wants::READ)
.await?;
let src = tokio::fs::read_to_string(&target)
let src = tokio::fs::read_to_string(&p)
.await
.map_err(|e| mlua::Error::external(format!("http.session: {e}")))?;
store = load_store("http.session", &src)?;
jar.merge_jsonl(&src);
}
make_session(&lua, client, Arc::new(Mutex::new(jar)))
}
}
let jar = Arc::new(CookieStoreMutex::new(store));
let client = build_client(Some(jar.clone())).map_err(mlua::Error::external)?;
make_session(&lua, client, jar)
})?,
)?;
@ -537,109 +527,3 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> {
// session metatable wrapping http.session().
lua.load(HTTP_LUA).set_name("@[stdlib/http]").exec()
}
#[cfg(test)]
mod tests {
//! Unit tests for the cookie-store policy this module relies on: these pin
//! the security properties (public-suffix, cookie tossing, Secure,
//! Max-Age) that motivated using `cookie_store` over a hand-rolled jar.
//! End-to-end behaviour is covered in `crate::lua_tests::http_tests`.
use super::*;
fn url(s: &str) -> reqwest::Url {
reqwest::Url::parse(s).unwrap()
}
fn names_for(store: &CookieStore, u: &str) -> Vec<String> {
let mut v: Vec<String> =
store.get_request_values(&url(u)).map(|(n, _)| n.to_string()).collect();
v.sort();
v
}
#[test]
fn origin_key_is_scheme_host_and_nondefault_port() {
// Credentials, path, query and fragment are stripped; the host is
// lowercased; a default port is omitted, a non-default one kept.
for (input, expected) in [
("https://example.com/a/b?q=1#frag", "https://example.com"),
("https://user:secret@Example.COM:443/x", "https://example.com"),
("http://user@example.com/", "http://example.com"),
("http://insecure.example.com:8090/api", "http://insecure.example.com:8090"),
("https://example.com:8443", "https://example.com:8443"),
] {
assert_eq!(origin_key(&url(input)).unwrap(), expected, "{input}");
}
let err = origin_key(&url("ftp://example.com/f")).unwrap_err().to_string();
assert!(err.contains("unsupported URL scheme"), "{err}");
}
#[test]
fn rejects_public_suffix_domain() {
let mut store = new_cookie_store();
// A TLD-scoped cookie would be sent to every .com host ("supercookie").
assert!(store.parse("x=1; Domain=com", &url("http://evil.com/")).is_err());
assert!(store.iter_any().next().is_none());
}
#[test]
fn rejects_foreign_domain() {
let mut store = new_cookie_store();
// Cookie tossing: a response must not plant a cookie for an unrelated
// domain, including a raw-substring "suffix" that is not a subdomain.
assert!(store.parse("x=1; Domain=other.example.com", &url("http://evil.com/")).is_err());
assert!(store.parse("x=1; Domain=example.com", &url("http://notexample.com/")).is_err());
assert!(store.iter_any().next().is_none());
}
#[test]
fn subdomain_may_scope_to_parent() {
let mut store = new_cookie_store();
store.parse("sid=abc; Domain=example.com", &url("http://app.example.com/")).unwrap();
// Sent to the parent and to sibling subdomains, not to strangers.
assert_eq!(names_for(&store, "http://example.com/"), ["sid"]);
assert_eq!(names_for(&store, "http://other.example.com/"), ["sid"]);
assert!(names_for(&store, "http://example.org/").is_empty());
}
#[test]
fn secure_cookie_not_sent_over_http() {
let mut store = new_cookie_store();
store.parse("s=1; Secure", &url("https://example.com/")).unwrap();
assert_eq!(names_for(&store, "https://example.com/"), ["s"]);
assert!(names_for(&store, "http://example.com/").is_empty());
}
#[test]
fn max_age_zero_expires_cookie() {
let u = url("http://example.com/");
let mut store = new_cookie_store();
store.parse("sid=abc", &u).unwrap();
assert_eq!(names_for(&store, "http://example.com/"), ["sid"]);
// How servers delete a cookie (e.g. logout).
let _ = store.parse("sid=; Max-Age=0", &u);
assert!(names_for(&store, "http://example.com/").is_empty());
}
#[test]
fn save_load_round_trips_session_cookies() {
let mut store = new_cookie_store();
// No Expires/Max-Age — a session cookie, which plain save would drop.
store.parse("sid=abc", &url("https://example.com/")).unwrap();
let mut buf = Vec::new();
cookie_store::serde::json::save_incl_expired_and_nonpersistent(&store, &mut buf).unwrap();
let loaded = load_store("test", std::str::from_utf8(&buf).unwrap()).unwrap();
assert_eq!(names_for(&loaded, "https://example.com/"), ["sid"]);
// The reloaded store must keep rejecting public-suffix domains.
let mut loaded = loaded;
assert!(loaded.parse("x=1; Domain=com", &url("http://evil.com/")).is_err());
}
#[test]
fn load_rejects_malformed_jar() {
assert!(load_store("test", "not json\n").is_err());
}
}

@ -5,17 +5,7 @@ fn args_to_string(lua: &Lua, args: Variadic<LuaValue>) -> LuaResult<String> {
let tostring: mlua::Function = lua.globals().get("tostring")?;
let parts: Vec<String> = args
.into_iter()
.map(|v| {
// tostring can yield a non-UTF-8 byte string (Lua strings are byte
// strings); render it lossily rather than collapsing the whole
// argument to "?" and losing its readable parts. "?" is reserved for
// the rare case where tostring itself errors (e.g. a broken
// __tostring metamethod).
match tostring.call::<mlua::String>(v) {
Ok(s) => s.to_string_lossy(),
Err(_) => "?".to_string(),
}
})
.map(|v| tostring.call::<String>(v).unwrap_or_else(|_| "?".to_string()))
.collect();
Ok(parts.join("\t"))
}
@ -65,28 +55,3 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> {
lua.globals().raw_set("log", log)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn non_utf8_arg_is_rendered_lossily_not_dropped() {
let lua = Lua::new();
// A byte string that is not valid UTF-8 must keep its readable parts
// (via lossy conversion) rather than collapsing the whole argument to "?".
let args: Variadic<LuaValue> =
Variadic::from_iter([LuaValue::String(lua.create_string(b"\xff readable").unwrap())]);
let rendered = args_to_string(&lua, args).unwrap();
assert!(rendered.contains("readable"), "got: {rendered:?}");
assert_ne!(rendered, "?");
}
#[test]
fn multiple_args_are_tab_joined() {
let lua = Lua::new();
let args: Variadic<LuaValue> =
Variadic::from_iter([LuaValue::Integer(1), LuaValue::Boolean(true)]);
assert_eq!(args_to_string(&lua, args).unwrap(), "1\ttrue");
}
}

@ -1,116 +0,0 @@
use std::borrow::Cow;
use mlua::Value as LuaValue;
use serde_json::Value as JsonValue;
/// Pretty-print a Lua value for humans (the backend of `utils.dump`).
/// Ported from flowbox-rt's fb_lua lua_dump.rs, adapted to mlua 0.11.
pub(super) fn dump(value: LuaValue) -> Cow<'static, str> {
dump_inner(value, DumpPosition::Value, 0)
}
enum DumpPosition {
Key,
Value,
}
fn keywrap(value: Cow<'static, str>, position: DumpPosition) -> Cow<'static, str> {
match position {
DumpPosition::Key => format!("[{value}]").into(),
DumpPosition::Value => value,
}
}
fn dump_inner(value: LuaValue, position: DumpPosition, recursion_depth: usize) -> Cow<'static, str> {
let res: Cow<'static, str> = match value {
LuaValue::Nil => "nil".into(),
LuaValue::Boolean(v) => if v { "true" } else { "false" }.into(),
LuaValue::Integer(v) => v.to_string().into(),
LuaValue::Number(v) => {
if v.fract() == 0.0 {
// ensure decimal places are shown to indicate it is a float
format!("{:.1}", v.trunc()).into()
} else {
v.to_string().into()
}
}
LuaValue::String(v) => {
let stringified = v.to_string_lossy().to_string();
match position {
DumpPosition::Key => {
// return to bypass the keywrap
return if stringified.char_indices().any(|(index, c)| {
if index == 0 {
!c.is_ascii_alphabetic() && c != '_'
} else {
!c.is_ascii_alphanumeric() && c != '_'
}
}) {
// json-serialize the string to add quotes and escapes
format!("[{}]", serde_json::to_string(&JsonValue::String(stringified)).unwrap()).into()
} else {
// string lua key can be used as is {foo="bar"}
stringified.into()
};
}
DumpPosition::Value => serde_json::to_string(&JsonValue::String(stringified)).unwrap().into(),
}
}
LuaValue::Table(t) => {
if recursion_depth > 20 {
// Flow through keywrap so a table used as a key still renders as
// `[<TRUNCATED>]`, consistent with every other key.
return keywrap("<TRUNCATED>".into(), position);
}
let recursion_depth = recursion_depth + 1;
let mut buf = "{".to_string();
let mut first = true;
let mut list_next = Some(1);
for pair in t.pairs::<LuaValue, LuaValue>() {
let Ok((k, v)) = pair else {
list_next = None;
continue;
};
if !first {
buf.push_str(", ");
}
first = false;
// special case for sequential numeric keys, starting at 1
if let Some(list_index) = list_next {
if let LuaValue::Integer(index) = k
&& index == list_index
{
buf.push_str(&dump_inner(v, DumpPosition::Value, recursion_depth));
list_next = Some(list_index + 1);
continue;
}
list_next = None;
}
buf.push_str(&format!(
"{}={}",
dump_inner(k, DumpPosition::Key, recursion_depth),
dump_inner(v, DumpPosition::Value, recursion_depth)
));
}
buf.push('}');
buf.into()
}
// The null pointer lightuserdata is the JSON null sentinel (utils.NULL)
LuaValue::LightUserData(ud) if ud.0.is_null() => "NULL".into(),
// Complex objects can't be displayed
LuaValue::LightUserData(_) => "<LightUserData>".into(),
LuaValue::Function(_) => "<Function>".into(),
LuaValue::Thread(_) => "<Thread>".into(),
LuaValue::UserData(_) => "<UserData>".into(),
LuaValue::Error(e) => format!("<Error:{e}>").into(),
other => format!("<{}>", other.type_name()).into(),
};
keywrap(res, position)
}

@ -1,29 +1,13 @@
// pub(crate) for ScriptDir — `main` plants the script's real directory for
// fs.getScriptDir().
pub(crate) mod fs;
mod http;
mod logging;
mod lua_dump;
mod math;
// pub(crate) for ScriptArgs — `main` plants the command-line arguments
// destined for the script (os.args()).
pub(crate) mod os_ext;
// pub(crate) for Policy & co. — `main` builds the permission policy from
// the CLI flags and the script's canonical path.
pub(crate) mod perm;
// Installed separately from `install`, since it needs the module search roots
// which are only known once the script path (or REPL CWD) is.
pub(crate) mod require;
mod os_ext;
mod sqlite;
mod table;
mod task;
// pub(crate) for tui::cleanup — `main` restores terminal state (alt screen,
// hidden cursor, …) on every exit path, including panics.
pub(crate) mod tui;
pub(crate) mod utils;
pub(crate) fn install(lua: &mlua::Lua) -> mlua::Result<()> {
perm::install(lua);
math::install(lua)?;
table::install(lua)?;
utils::install(lua)?;
@ -31,8 +15,6 @@ pub(crate) fn install(lua: &mlua::Lua) -> mlua::Result<()> {
logging::install(lua)?;
sqlite::install(lua)?;
http::install(lua)?;
fs::install(lua)?;
task::install(lua)?;
tui::install(lua)?;
Ok(())
}

@ -1,36 +1,12 @@
use std::ffi::OsString;
use std::os::unix::ffi::OsStrExt;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use mlua::prelude::LuaResult;
use mlua::{Lua, Table as LuaTable, Value as LuaValue};
/// Command-line arguments destined for the script — everything after the
/// script path (or after `--`) on the `luna` command line. Planted into app
/// data by `main`; absent in tests, where `os.args()` returns an empty table.
pub(crate) struct ScriptArgs(pub(crate) Vec<OsString>);
use mlua::{Lua, Table as LuaTable};
/// Extend the already-sandboxed `os` table with non-standard additions.
pub(super) fn install(lua: &Lua) -> LuaResult<()> {
let os: LuaTable = lua.globals().get("os")?;
// os.args() — the arguments passed to the script, as a fresh array each
// call so the caller can mutate the result freely.
os.raw_set(
"args",
lua.create_function(|lua, ()| {
let table = lua.create_table()?;
if let Some(args) = lua.app_data_ref::<ScriptArgs>() {
for (i, arg) in args.0.iter().enumerate() {
// OS arguments are arbitrary bytes, and so are Lua strings —
// no lossy UTF-8 conversion.
table.raw_set(i + 1, lua.create_string(arg.as_bytes())?)?;
}
}
Ok(table)
})?,
)?;
// os.microtime() — Unix timestamp as f64 with sub-second precision.
os.raw_set(
"microtime",
@ -45,33 +21,7 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> {
// os.sleep(seconds) — yields the Lua coroutine via tokio; does not block the thread.
os.raw_set(
"sleep",
lua.create_async_function(|_, seconds: LuaValue| async move {
// Accept only a number, with a prefixed message (mlua's own coercion
// would report a bare "error converting ..." for e.g. a string).
let seconds = match seconds {
LuaValue::Integer(i) => i as f64,
LuaValue::Number(n) => n,
other => {
return Err(mlua::Error::external(format!(
"os.sleep: duration must be a number (got {})",
other.type_name()
)));
}
};
// Cap the delay: a huge-but-finite value (e.g. 1e18) would otherwise
// park the task effectively forever with no way to interrupt it from
// Lua, which reads as the runtime hanging rather than the script.
const MAX_SLEEP_SECS: f64 = 24.0 * 60.0 * 60.0; // one day
if !(seconds.is_finite() && seconds >= 0.0) {
return Err(mlua::Error::external(
"os.sleep: duration must be a non-negative finite number of seconds",
));
}
if seconds > MAX_SLEEP_SECS {
return Err(mlua::Error::external(format!(
"os.sleep: refusing to sleep for more than {MAX_SLEEP_SECS} seconds (got {seconds})"
)));
}
lua.create_async_function(|_, seconds: f64| async move {
let duration = Duration::try_from_secs_f64(seconds).map_err(|_| {
mlua::Error::external(
"os.sleep: duration must be a non-negative finite number of seconds",

@ -1,92 +0,0 @@
//! The per-script permission system, shared by every subsystem that reaches
//! outside the sandbox.
//!
//! Two kinds of resources are guarded:
//!
//! - **Directories** — the `fs` module, sqlite databases, http cookie-jar
//! files. Grants are per canonical directory with separate read/write
//! flags, and apply recursively (the deepest recorded ancestor decides).
//! - **Network origins** — the `http` module. Grants are per exact origin
//! (`scheme://host[:port]`) with a single access flag.
//!
//! Recorded answers live in `access.json` ([`store`]), unknown ones are asked
//! on the terminal ([`prompt`]) — `y`/`n` for this run, `A`/`N` recorded.
//! Grants are keyed by the script's canonical path; the REPL prompts the same
//! way but never persists. CLI flags (`--allow-fs`, `--no-fs`, `--allow-net`,
//! `--no-net`, `--sandbox`) statically override one or both resource kinds
//! (see `main.rs`).
pub(crate) mod policy;
// In test builds `Policy::check_*` never reach the real terminal prompt
// (scripted answers only), leaving these functions deliberately unused.
#[cfg_attr(test, allow(dead_code))]
mod prompt;
pub(crate) mod store;
use std::path::PathBuf;
use std::sync::Arc;
use mlua::prelude::LuaResult;
use mlua::Lua;
pub(crate) use policy::{Override, Policy, Wants};
#[cfg(test)]
pub(crate) use prompt::Choice;
/// `mlua::Error::external(format!(...))` — every Lua-facing error here goes
/// through this, prefixed with the name of the operation being denied.
macro_rules! ext_err {
($($arg:tt)*) => { mlua::Error::external(format!($($arg)*)) };
}
/// Plant the default policy: REPL semantics — prompt if a terminal is there,
/// never persist. `main` replaces it once the CLI flags and the script's
/// canonical path are known; bare states (tests) can therefore never touch
/// the real access.json.
pub(super) fn install(lua: &Lua) {
lua.set_app_data(Arc::new(Policy::interactive(None, Default::default(), None)));
}
/// The active policy; fails closed if none was installed (cannot happen —
/// `install` plants a default).
fn active_policy(lua: &Lua) -> Arc<Policy> {
lua.app_data_ref::<Arc<Policy>>()
.map(|p| Arc::clone(&p))
.unwrap_or_else(|| Arc::new(Policy::deny_all("(internal: no permission policy)")))
}
/// Run the permission cycle for the canonical directory `dir`; a denial is a
/// Lua error prefixed with `fname`. Shared by fs, sqlite (`Wants::READ_WRITE`
/// on a database's directory) and http (cookie-jar files).
pub(super) async fn ensure_dir_access(
lua: &Lua,
fname: &'static str,
dir: PathBuf,
wants: Wants,
) -> LuaResult<()> {
match active_policy(lua).check_dir(dir.clone(), wants).await? {
policy::Outcome::Granted => Ok(()),
policy::Outcome::DeniedByFlag(flag) => {
Err(ext_err!("{fname}: filesystem access disabled by {flag}"))
}
policy::Outcome::Denied => Err(ext_err!("{fname}: access to {} denied", dir.display())),
}
}
/// Bool-returning directory permission cycle (`fs.access`, the predicates):
/// denial is `false`, never an error.
pub(super) async fn check_dir_access(lua: &Lua, dir: PathBuf, wants: Wants) -> LuaResult<bool> {
Ok(matches!(active_policy(lua).check_dir(dir, wants).await?, policy::Outcome::Granted))
}
/// Run the permission cycle for a network origin (`scheme://host[:port]`);
/// a denial is a Lua error. Called by http before every request.
pub(super) async fn ensure_origin_access(lua: &Lua, origin: &str) -> LuaResult<()> {
match active_policy(lua).check_origin(origin.to_string()).await? {
policy::Outcome::Granted => Ok(()),
policy::Outcome::DeniedByFlag(flag) => {
Err(ext_err!("http: networking disabled by {flag}"))
}
policy::Outcome::Denied => Err(ext_err!("http: access to {origin} denied")),
}
}

@ -1,775 +0,0 @@
//! The permission policy — who may touch which directory or network origin.
//!
//! One [`Policy`] lives in mlua app data per Lua state (as `Arc<Policy>`).
//! `perm::install` plants a session-only default; `main` replaces it once the
//! CLI flags and the script's canonical path are known.
//!
//! Directory grants are recorded per canonical directory and apply
//! recursively: the deepest recorded ancestor decides, so a deny on
//! `~/secrets` beats an allow on `~`. Origin grants are exact-match — a grant
//! for `https://example.com` says nothing about other hosts, subdomains,
//! schemes or ports. Session answers (y/n, and everything in the REPL) shadow
//! the persisted store at every depth.
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use mlua::prelude::LuaResult;
use super::prompt::Choice;
use super::store::{self, DirAccess, DirGrants, OriginAccess, ScriptGrants};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum AccessKind {
Read,
Write,
}
/// Which directions a directory operation needs; `read && write` is sqlite's
/// combined "open this database" request, answered by a single prompt.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Wants {
pub(crate) read: bool,
pub(crate) write: bool,
}
impl Wants {
pub(crate) const READ: Wants = Wants { read: true, write: false };
pub(crate) const WRITE: Wants = Wants { read: false, write: true };
pub(crate) const READ_WRITE: Wants = Wants { read: true, write: true };
fn kinds(self) -> impl Iterator<Item = AccessKind> {
[(self.read, AccessKind::Read), (self.write, AccessKind::Write)]
.into_iter()
.filter_map(|(wanted, kind)| wanted.then_some(kind))
}
fn describe(self) -> &'static str {
match (self.read, self.write) {
(true, true) => "read/write",
(true, false) => "read",
(false, true) => "write",
(false, false) => "no",
}
}
}
/// A CLI flag's static answer for one resource kind; interactive when absent.
#[derive(Clone, Copy, Debug)]
pub(crate) enum Override {
/// `--allow-fs` / `--allow-net`: granted, no prompts, no persistence.
Allow,
/// `--no-fs` / `--no-net` / `--sandbox`: denied; `flag` names the culprit
/// for error messages.
Deny { flag: &'static str },
}
pub(crate) struct Policy {
/// Static answer for directory checks; `None` = interactive.
fs_override: Option<Override>,
/// Static answer for origin checks; `None` = interactive.
net_override: Option<Override>,
/// Store key = the script's canonical path. `None` in the REPL (and in
/// bare test states): prompts work, nothing is ever persisted.
script_key: Option<String>,
/// This script's grants from access.json, snapshotted at startup.
persisted: Mutex<ScriptGrants>,
/// Session-only answers — y/n, plus A/N in the REPL. Shadows `persisted`.
session: Mutex<ScriptGrants>,
/// Where A/N answers are persisted; `None` = no config dir found,
/// session-only operation.
store_path: Option<PathBuf>,
/// Scripted prompt answers for tests, consumed front to back; an
/// exhausted (or absent) queue behaves like "no terminal". In test
/// builds the real terminal prompt is never reached (see `prompted`).
#[cfg(test)]
scripted: Mutex<std::collections::VecDeque<Choice>>,
}
/// Outcome of a permission check, kept apart from granted/denied booleans so
/// error messages can name the CLI flag that caused a static denial.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Outcome {
Granted,
/// Denied by `--no-fs` / `--no-net` / `--sandbox`.
DeniedByFlag(&'static str),
/// Denied by a recorded decision, an interactive answer, or the inability
/// to ask (no terminal).
Denied,
}
impl Outcome {
fn from_decided(granted: bool) -> Outcome {
if granted { Outcome::Granted } else { Outcome::Denied }
}
}
impl Policy {
pub(crate) fn new(
fs_override: Option<Override>,
net_override: Option<Override>,
script_key: Option<String>,
persisted: ScriptGrants,
store_path: Option<PathBuf>,
) -> Self {
Policy {
fs_override,
net_override,
script_key,
persisted: Mutex::new(persisted),
session: Mutex::new(ScriptGrants::default()),
store_path,
#[cfg(test)]
scripted: Mutex::new(std::collections::VecDeque::new()),
}
}
/// Everything granted, no prompts, no persistence. Tests only — the CLI
/// composes the equivalent from two `Override::Allow`s.
#[cfg(test)]
pub(crate) fn allow_all() -> Self {
Self::new(Some(Override::Allow), Some(Override::Allow), None, Default::default(), None)
}
/// Everything denied (`--sandbox`).
pub(crate) fn deny_all(flag: &'static str) -> Self {
Self::new(
Some(Override::Deny { flag }),
Some(Override::Deny { flag }),
None,
Default::default(),
None,
)
}
/// Fully interactive: consult the recorded grants, prompt on unknown.
pub(crate) fn interactive(
script_key: Option<String>,
persisted: ScriptGrants,
store_path: Option<PathBuf>,
) -> Self {
Self::new(None, None, script_key, persisted, store_path)
}
/// An interactive policy with a queue of pre-recorded prompt answers.
#[cfg(test)]
pub(crate) fn interactive_scripted(
script_key: Option<String>,
persisted: ScriptGrants,
store_path: Option<PathBuf>,
answers: impl IntoIterator<Item = Choice>,
) -> Self {
let policy = Self::interactive(script_key, persisted, store_path);
policy.scripted.lock().unwrap().extend(answers);
policy
}
/// The full permission cycle for the canonical directory `dir`: recorded
/// answer, else ask on the terminal, else deny (recording nothing — a
/// persisted denial must be an explicit user decision).
pub(crate) async fn check_dir(
self: Arc<Self>,
dir: PathBuf,
wants: Wants,
) -> LuaResult<Outcome> {
match self.fs_override {
Some(Override::Allow) => return Ok(Outcome::Granted),
Some(Override::Deny { flag }) => return Ok(Outcome::DeniedByFlag(flag)),
None => {}
}
// Fast path — already decided, no prompt needed.
if let Some(decided) = self.resolve_dir_wants(&dir, wants) {
return Ok(Outcome::from_decided(decided));
}
self.prompted(move |policy, ask| policy.decide_dir(&dir, wants, ask)).await
}
/// The full permission cycle for a network origin
/// (`scheme://host[:port]`), same shape as [`check_dir`](Self::check_dir).
pub(crate) async fn check_origin(self: Arc<Self>, origin: String) -> LuaResult<Outcome> {
match self.net_override {
Some(Override::Allow) => return Ok(Outcome::Granted),
Some(Override::Deny { flag }) => return Ok(Outcome::DeniedByFlag(flag)),
None => {}
}
if let Some(decided) = self.resolve_origin(&origin) {
return Ok(Outcome::from_decided(decided));
}
self.prompted(move |policy, ask| policy.decide_origin(&origin, ask)).await
}
/// Run an ask-and-record step. In test builds the scripted answer queue
/// stands in for the terminal — structurally, an unwitting test cannot
/// hang `cargo test` on a keypress. In real builds the step runs on the
/// blocking pool under the tui prompt lock, so concurrent coroutines
/// can't fight over the terminal.
async fn prompted(
self: Arc<Self>,
decide: impl FnOnce(&Policy, &dyn Fn(&str, &str) -> Choice) -> Outcome + Send + 'static,
) -> LuaResult<Outcome> {
#[cfg(test)]
{
let choice = {
let mut answers = self.scripted.lock().unwrap_or_else(|e| e.into_inner());
answers.pop_front().unwrap_or(Choice::NoTty)
};
Ok(decide(&self, &move |_: &str, _: &str| choice))
}
#[cfg(not(test))]
{
if !super::prompt::can_prompt() {
return Ok(Outcome::Denied);
}
let this = Arc::clone(&self);
tokio::task::spawn_blocking(move || {
let _guard = crate::stdlib::tui::PROMPT_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
decide(&this, &super::prompt::prompt_access)
})
.await
.map_err(|e| mlua::Error::external(format!("permission prompt task failed: {e}")))
}
}
/// The directory ask-and-record step, run with the prompt lock held.
/// Re-resolves first — another coroutine may have answered for this
/// directory while we waited for the lock — then asks only for what is
/// still unknown.
fn decide_dir(&self, dir: &Path, wants: Wants, ask: &dyn Fn(&str, &str) -> Choice) -> Outcome {
if let Some(decided) = self.resolve_dir_wants(dir, wants) {
return Outcome::from_decided(decided);
}
let missing = Wants {
read: wants.read && self.resolve_dir_one(dir, AccessKind::Read).is_none(),
write: wants.write && self.resolve_dir_one(dir, AccessKind::Write).is_none(),
};
let dir_key = dir.to_string_lossy().into_owned();
let choice = ask(
self.script_label(),
&format!("{} access to directory {dir_key}", missing.describe()),
);
let Some(value) = choice.value() else {
return Outcome::Denied; // no terminal: record nothing
};
let update = DirAccess {
read: missing.read.then_some(value),
write: missing.write.then_some(value),
};
{
let mut session = self.session.lock().unwrap_or_else(|e| e.into_inner());
let entry = session.fs.entry(dir_key.clone()).or_default();
if update.read.is_some() {
entry.read = update.read;
}
if update.write.is_some() {
entry.write = update.write;
}
}
if choice.is_always()
&& let (Some(key), Some(path)) = (&self.script_key, &self.store_path)
&& let Err(e) = store::persist_fs(path, key, &dir_key, update)
{
// The answer still holds for this run; failing to record it must
// not fail the script's operation.
warn_cannot_save(path, e);
}
// `missing` now carries the fresh answer; every other wanted kind
// already resolved to true (a false would have denied above).
Outcome::from_decided(value)
}
/// The origin ask-and-record step, run with the prompt lock held.
fn decide_origin(&self, origin: &str, ask: &dyn Fn(&str, &str) -> Choice) -> Outcome {
if let Some(decided) = self.resolve_origin(origin) {
return Outcome::from_decided(decided);
}
let choice = ask(self.script_label(), &format!("access to {origin}"));
let Some(value) = choice.value() else {
return Outcome::Denied; // no terminal: record nothing
};
self.session
.lock()
.unwrap_or_else(|e| e.into_inner())
.net
.insert(origin.to_string(), OriginAccess { access: Some(value) });
if choice.is_always()
&& let (Some(key), Some(path)) = (&self.script_key, &self.store_path)
&& let Err(e) = store::persist_net(path, key, origin, value)
{
warn_cannot_save(path, e);
}
Outcome::from_decided(value)
}
fn script_label(&self) -> &str {
self.script_key.as_deref().unwrap_or("interactive session (REPL)")
}
/// `Some(true)` = every wanted kind granted, `Some(false)` = at least one
/// denied, `None` = at least one undecided (and none denied).
fn resolve_dir_wants(&self, dir: &Path, wants: Wants) -> Option<bool> {
let mut all_known = true;
for kind in wants.kinds() {
match self.resolve_dir_one(dir, kind) {
Some(false) => return Some(false),
Some(true) => {}
None => all_known = false,
}
}
all_known.then_some(true)
}
fn resolve_dir_one(&self, dir: &Path, kind: AccessKind) -> Option<bool> {
let session = self.session.lock().unwrap_or_else(|e| e.into_inner());
let persisted = self.persisted.lock().unwrap_or_else(|e| e.into_inner());
resolve_dir(&session.fs, &persisted.fs, dir, kind)
}
/// Exact-match origin lookup, session layer first.
fn resolve_origin(&self, origin: &str) -> Option<bool> {
let session = self.session.lock().unwrap_or_else(|e| e.into_inner());
let persisted = self.persisted.lock().unwrap_or_else(|e| e.into_inner());
[&session.net, &persisted.net]
.into_iter()
.find_map(|layer| layer.get(origin).and_then(|a| a.access))
}
}
impl Choice {
/// The yes/no the choice stands for; `None` when there was nobody to ask.
fn value(self) -> Option<bool> {
match self {
Choice::AllowOnce | Choice::AllowAlways => Some(true),
Choice::DenyOnce | Choice::DenyAlways => Some(false),
Choice::NoTty => None,
}
}
fn is_always(self) -> bool {
matches!(self, Choice::AllowAlways | Choice::DenyAlways)
}
}
fn warn_cannot_save(path: &Path, e: std::io::Error) {
eprintln!(
"{}: warning: cannot save permission to {}: {e}",
store::APP_NAME,
path.display()
);
}
/// Walk from `dir` up to and including `/`. At each depth the session layer
/// shadows the persisted one; the first decided value wins — the deepest
/// recorded ancestor decides.
fn resolve_dir(
session: &DirGrants,
persisted: &DirGrants,
dir: &Path,
kind: AccessKind,
) -> Option<bool> {
let mut cur = Some(dir);
while let Some(d) = cur {
let key = d.to_string_lossy();
for layer in [session, persisted] {
if let Some(v) = layer.get(key.as_ref()).and_then(|a| a.get(kind)) {
return Some(v);
}
}
cur = d.parent();
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn grants(entries: &[(&str, Option<bool>, Option<bool>)]) -> ScriptGrants {
ScriptGrants {
fs: entries
.iter()
.map(|(dir, read, write)| {
(dir.to_string(), DirAccess { read: *read, write: *write })
})
.collect(),
..Default::default()
}
}
fn origin_grants(entries: &[(&str, bool)]) -> ScriptGrants {
ScriptGrants {
net: entries
.iter()
.map(|(origin, allowed)| {
(origin.to_string(), OriginAccess { access: Some(*allowed) })
})
.collect(),
..Default::default()
}
}
#[test]
fn resolve_dir_walks_ancestors() {
let persisted = grants(&[("/home/u/data", Some(true), None)]).fs;
let session = DirGrants::new();
let r = |dir: &str| resolve_dir(&session, &persisted, Path::new(dir), AccessKind::Read);
assert_eq!(r("/home/u/data"), Some(true), "exact hit");
assert_eq!(r("/home/u/data/sub/deep"), Some(true), "parent covers children");
assert_eq!(r("/home/u"), None, "no upward leakage");
assert_eq!(r("/elsewhere"), None, "unrelated dir unknown");
}
#[test]
fn resolve_dir_deepest_ancestor_wins() {
let persisted = grants(&[
("/home/u", Some(true), None),
("/home/u/secrets", Some(false), None),
])
.fs;
let session = DirGrants::new();
let r = |dir: &str| resolve_dir(&session, &persisted, Path::new(dir), AccessKind::Read);
assert_eq!(r("/home/u/data"), Some(true));
assert_eq!(r("/home/u/secrets"), Some(false), "deny beats shallower allow");
assert_eq!(r("/home/u/secrets/inner"), Some(false));
}
#[test]
fn resolve_dir_session_shadows_persisted() {
let persisted = grants(&[("/data", Some(false), None)]).fs;
let session = grants(&[("/data", Some(true), None)]).fs;
assert_eq!(
resolve_dir(&session, &persisted, Path::new("/data/x"), AccessKind::Read),
Some(true)
);
}
#[test]
fn resolve_dir_null_flags_are_skipped() {
// read is undecided at the deep entry; the shallow decided one wins.
let persisted = grants(&[("/a", Some(true), None), ("/a/b", None, Some(false))]).fs;
let session = DirGrants::new();
assert_eq!(
resolve_dir(&session, &persisted, Path::new("/a/b"), AccessKind::Read),
Some(true)
);
assert_eq!(
resolve_dir(&session, &persisted, Path::new("/a/b"), AccessKind::Write),
Some(false)
);
}
#[test]
fn resolve_dir_root_grant_covers_everything() {
let persisted = grants(&[("/", Some(true), Some(true))]).fs;
let session = DirGrants::new();
assert_eq!(
resolve_dir(&session, &persisted, Path::new("/any/where"), AccessKind::Write),
Some(true)
);
}
#[tokio::test]
async fn static_overrides() {
let allow = Arc::new(Policy::allow_all());
assert_eq!(
Arc::clone(&allow).check_dir("/x".into(), Wants::READ).await.unwrap(),
Outcome::Granted
);
assert_eq!(
allow.check_origin("https://example.com".into()).await.unwrap(),
Outcome::Granted
);
let deny = Arc::new(Policy::deny_all("--sandbox"));
assert_eq!(
Arc::clone(&deny).check_dir("/x".into(), Wants::WRITE).await.unwrap(),
Outcome::DeniedByFlag("--sandbox")
);
assert_eq!(
deny.check_origin("https://example.com".into()).await.unwrap(),
Outcome::DeniedByFlag("--sandbox")
);
// Mixed: --no-fs leaves origins interactive (here: no answers = deny,
// but not by flag).
let mixed = Arc::new(Policy::new(
Some(Override::Deny { flag: "--no-fs" }),
None,
None,
Default::default(),
None,
));
assert_eq!(
Arc::clone(&mixed).check_dir("/x".into(), Wants::READ).await.unwrap(),
Outcome::DeniedByFlag("--no-fs")
);
assert_eq!(
mixed.check_origin("https://example.com".into()).await.unwrap(),
Outcome::Denied
);
}
#[tokio::test]
async fn allow_once_grants_for_the_whole_run() {
let policy = Arc::new(Policy::interactive_scripted(
None,
Default::default(),
None,
[Choice::AllowOnce], // exactly one answer available
));
let p = Arc::clone(&policy);
assert_eq!(p.check_dir("/data".into(), Wants::READ).await.unwrap(), Outcome::Granted);
// second check hits the session cache — no second answer consumed
// (the queue is empty; consuming would mean NoTty => Denied)
let p = Arc::clone(&policy);
assert_eq!(p.check_dir("/data/sub".into(), Wants::READ).await.unwrap(), Outcome::Granted);
}
#[tokio::test]
async fn deny_once_is_cached_for_the_run() {
let policy = Arc::new(Policy::interactive_scripted(
None,
Default::default(),
None,
[Choice::DenyOnce],
));
let p = Arc::clone(&policy);
assert_eq!(p.check_dir("/data".into(), Wants::READ).await.unwrap(), Outcome::Denied);
let p = Arc::clone(&policy);
assert_eq!(p.check_dir("/data".into(), Wants::READ).await.unwrap(), Outcome::Denied);
}
#[tokio::test]
async fn allow_always_persists_and_covers_subdirs() {
let tmp = tempfile::tempdir().unwrap();
let store_path = tmp.path().join("access.json");
let policy = Arc::new(Policy::interactive_scripted(
Some("/s/tool.lua".into()),
Default::default(),
Some(store_path.clone()),
[Choice::AllowAlways],
));
let p = Arc::clone(&policy);
assert_eq!(p.check_dir("/data".into(), Wants::READ).await.unwrap(), Outcome::Granted);
let saved = store::load_for_script(&store_path, "/s/tool.lua");
assert_eq!(saved.fs["/data"], DirAccess { read: Some(true), write: None });
let p = Arc::clone(&policy);
assert_eq!(p.check_dir("/data/deep".into(), Wants::READ).await.unwrap(), Outcome::Granted);
}
#[tokio::test]
async fn deny_always_persists_false() {
let tmp = tempfile::tempdir().unwrap();
let store_path = tmp.path().join("access.json");
let policy = Arc::new(Policy::interactive_scripted(
Some("/s/tool.lua".into()),
Default::default(),
Some(store_path.clone()),
[Choice::DenyAlways],
));
let p = Arc::clone(&policy);
assert_eq!(p.check_dir("/secrets".into(), Wants::WRITE).await.unwrap(), Outcome::Denied);
let saved = store::load_for_script(&store_path, "/s/tool.lua");
assert_eq!(saved.fs["/secrets"], DirAccess { read: None, write: Some(false) });
}
#[tokio::test]
async fn repl_always_answers_never_persist() {
let tmp = tempfile::tempdir().unwrap();
let store_path = tmp.path().join("access.json");
// script_key = None (REPL): store_path present but must not be used
let policy = Arc::new(Policy::interactive_scripted(
None,
Default::default(),
Some(store_path.clone()),
[Choice::AllowAlways],
));
let p = Arc::clone(&policy);
assert_eq!(p.check_dir("/data".into(), Wants::READ).await.unwrap(), Outcome::Granted);
assert!(!store_path.exists(), "REPL grant must not create the store");
// ...but it holds for the session
let p = Arc::clone(&policy);
assert_eq!(p.check_dir("/data".into(), Wants::READ).await.unwrap(), Outcome::Granted);
}
#[tokio::test]
async fn unknown_without_terminal_denies_and_records_nothing() {
let tmp = tempfile::tempdir().unwrap();
let store_path = tmp.path().join("access.json");
// empty answer queue = "no terminal"
let policy = Arc::new(Policy::interactive_scripted(
Some("/s/tool.lua".into()),
Default::default(),
Some(store_path.clone()),
[],
));
let p = Arc::clone(&policy);
assert_eq!(p.check_dir("/data".into(), Wants::READ).await.unwrap(), Outcome::Denied);
assert!(!store_path.exists());
// nothing was cached either: a persisted grant arriving later (other
// process) would be honored — verify session stayed empty
assert!(policy.session.lock().unwrap().fs.is_empty());
}
#[tokio::test]
async fn persisted_grants_answer_without_prompting() {
let persisted = grants(&[("/data", Some(true), Some(false))]);
let policy = Arc::new(Policy::interactive_scripted(
Some("/s/tool.lua".into()),
persisted,
None,
[], // no answers available: any prompt would deny
));
let p = Arc::clone(&policy);
assert_eq!(p.check_dir("/data/x".into(), Wants::READ).await.unwrap(), Outcome::Granted);
let p = Arc::clone(&policy);
assert_eq!(p.check_dir("/data/x".into(), Wants::WRITE).await.unwrap(), Outcome::Denied);
// combined read/write: the recorded write=false denies without asking
let p = Arc::clone(&policy);
assert_eq!(
p.check_dir("/data/x".into(), Wants::READ_WRITE).await.unwrap(),
Outcome::Denied
);
}
#[tokio::test]
async fn combined_prompt_records_only_missing_kinds() {
let tmp = tempfile::tempdir().unwrap();
let store_path = tmp.path().join("access.json");
// read already granted; a READ_WRITE check must only ask (and record)
// the missing write flag
let persisted = grants(&[("/db", Some(true), None)]);
let policy = Arc::new(Policy::interactive_scripted(
Some("/s/tool.lua".into()),
persisted,
Some(store_path.clone()),
[Choice::AllowAlways],
));
let p = Arc::clone(&policy);
assert_eq!(p.check_dir("/db".into(), Wants::READ_WRITE).await.unwrap(), Outcome::Granted);
let saved = store::load_for_script(&store_path, "/s/tool.lua");
assert_eq!(
saved.fs["/db"],
DirAccess { read: None, write: Some(true) },
"read was already known; only write gets recorded"
);
}
#[tokio::test]
async fn origin_allow_once_is_cached_for_the_run() {
let policy = Arc::new(Policy::interactive_scripted(
None,
Default::default(),
None,
[Choice::AllowOnce], // exactly one answer available
));
let p = Arc::clone(&policy);
assert_eq!(p.check_origin("https://example.com".into()).await.unwrap(), Outcome::Granted);
// same origin again: session cache, no answer consumed
let p = Arc::clone(&policy);
assert_eq!(p.check_origin("https://example.com".into()).await.unwrap(), Outcome::Granted);
}
#[tokio::test]
async fn origin_grants_are_exact_match_only() {
// A grant covers exactly its origin: not another scheme, port,
// subdomain, or a host that merely starts with it.
let persisted = origin_grants(&[("https://example.com", true)]);
let policy = Arc::new(Policy::interactive_scripted(
Some("/s/tool.lua".into()),
persisted,
None,
[], // any prompt denies
));
for (origin, expected) in [
("https://example.com", Outcome::Granted),
("http://example.com", Outcome::Denied),
("https://example.com:8443", Outcome::Denied),
("https://sub.example.com", Outcome::Denied),
("https://example.com.evil.io", Outcome::Denied),
] {
let p = Arc::clone(&policy);
assert_eq!(p.check_origin(origin.into()).await.unwrap(), expected, "{origin}");
}
}
#[tokio::test]
async fn origin_always_answers_persist_under_net() {
let tmp = tempfile::tempdir().unwrap();
let store_path = tmp.path().join("access.json");
let policy = Arc::new(Policy::interactive_scripted(
Some("/s/tool.lua".into()),
Default::default(),
Some(store_path.clone()),
[Choice::AllowAlways, Choice::DenyAlways],
));
let p = Arc::clone(&policy);
assert_eq!(
p.check_origin("https://api.example.com".into()).await.unwrap(),
Outcome::Granted
);
let p = Arc::clone(&policy);
assert_eq!(
p.check_origin("http://tracker.example.com".into()).await.unwrap(),
Outcome::Denied
);
let saved = store::load_for_script(&store_path, "/s/tool.lua");
assert_eq!(saved.net["https://api.example.com"].access, Some(true));
assert_eq!(saved.net["http://tracker.example.com"].access, Some(false));
assert!(saved.fs.is_empty(), "no directory grants were involved");
// a fresh policy loading the store answers without prompting
let policy = Arc::new(Policy::interactive_scripted(
Some("/s/tool.lua".into()),
store::load_for_script(&store_path, "/s/tool.lua"),
Some(store_path.clone()),
[],
));
let p = Arc::clone(&policy);
assert_eq!(
p.check_origin("https://api.example.com".into()).await.unwrap(),
Outcome::Granted
);
let p = Arc::clone(&policy);
assert_eq!(
p.check_origin("http://tracker.example.com".into()).await.unwrap(),
Outcome::Denied
);
}
#[tokio::test]
async fn origin_and_dir_grants_do_not_cross_talk() {
// An fs grant whose key happens to look origin-ish must not answer an
// origin check, and vice versa.
let mut persisted = grants(&[("/data", Some(true), Some(true))]);
persisted.net.insert("https://example.com".into(), OriginAccess { access: Some(true) });
let policy = Arc::new(Policy::interactive_scripted(
Some("/s/tool.lua".into()),
persisted,
None,
[],
));
let p = Arc::clone(&policy);
assert_eq!(p.check_origin("/data".into()).await.unwrap(), Outcome::Denied);
let p = Arc::clone(&policy);
assert_eq!(
p.check_dir("https://example.com".into(), Wants::READ).await.unwrap(),
Outcome::Denied
);
}
}

@ -1,110 +0,0 @@
//! The single-keypress permission prompt.
//!
//! Runs on the blocking pool with the tui prompt lock held (see
//! [`super::policy::Policy`]), so it never fights an inquire prompt
//! or a sibling permission prompt for the terminal. One keypress, no Enter:
//! `y`/`n` answer for this run only, `A`/`N` are recorded in access.json.
//! Deliberately, a lowercase `a` does nothing — a slip of the finger must not
//! persist anything.
use std::io::{IsTerminal, Write};
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
use super::store::APP_NAME;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Choice {
AllowOnce,
DenyOnce,
AllowAlways,
DenyAlways,
/// No terminal to ask on (or raw mode unavailable): deny, record nothing.
NoTty,
}
/// Whether there is a terminal to ask on. The prompt itself goes to stderr so
/// it shows even when the script's stdout is piped.
pub(super) fn can_prompt() -> bool {
std::io::stdin().is_terminal() && std::io::stderr().is_terminal()
}
/// Ask the user; `wants` describes the request, e.g. `"read access to
/// directory /data"` or `"access to https://example.com"`.
pub(super) fn prompt_access(script: &str, wants: &str) -> Choice {
if !can_prompt() {
return Choice::NoTty;
}
// The leading clear-line erases anything a concurrent coroutine left on
// the current line before the prompt took the terminal (e.g. a spinner
// frame) — further output is held back by the prompt lock, which the
// caller already holds.
eprint!(
"\x1b[2K\r{APP_NAME}: permission request\n \
script: {script}\n \
wants: {wants}\n\
[y] allow once [n] deny once [A] allow always [N] never allow: "
);
let _ = std::io::stderr().flush();
let Ok(raw) = RawMode::enable() else {
eprintln!();
return Choice::NoTty;
};
let choice = loop {
match crossterm::event::read() {
Ok(Event::Key(k)) if k.kind == KeyEventKind::Press => {
// In raw mode Ctrl-C is a plain key event (no SIGINT): a
// cautious "deny once", same as Esc.
if k.modifiers.contains(KeyModifiers::CONTROL) {
if k.code == KeyCode::Char('c') {
break Choice::DenyOnce;
}
continue;
}
match k.code {
KeyCode::Char('y') => break Choice::AllowOnce,
KeyCode::Char('n') => break Choice::DenyOnce,
// Shifted letters arrive as the uppercase char (the SHIFT
// modifier bit varies by terminal, so don't match on it).
KeyCode::Char('A') => break Choice::AllowAlways,
KeyCode::Char('N') => break Choice::DenyAlways,
KeyCode::Esc => break Choice::DenyOnce,
_ => {}
}
}
Ok(_) => {} // resize, mouse, focus: ignore
Err(_) => break Choice::DenyOnce, // fail closed
}
};
drop(raw);
// Echo the decision after raw mode is off (raw mode needs \r\n).
eprintln!(
"{}",
match choice {
Choice::AllowOnce => "allow once",
Choice::DenyOnce => "deny once",
Choice::AllowAlways => "allow always",
Choice::DenyAlways => "never allow",
Choice::NoTty => "",
}
);
choice
}
/// RAII raw-mode: a panic between enable and disable can't strand the
/// terminal in raw mode.
struct RawMode;
impl RawMode {
fn enable() -> std::io::Result<RawMode> {
crossterm::terminal::enable_raw_mode()?;
Ok(RawMode)
}
}
impl Drop for RawMode {
fn drop(&mut self) {
let _ = crossterm::terminal::disable_raw_mode();
}
}

@ -1,432 +0,0 @@
//! The persistent grant store — `access.json` in the user's config directory.
//!
//! Grants are keyed by the script's canonical path, then by subsystem section:
//! `fs` maps canonical directories to a tri-state per direction, `net` maps
//! network origins to a single tri-state `access` flag — `null` (not
//! decided), `true` (approved), `false` (denied). The file is hand-editable;
//! the persist functions keep concurrent Luna processes from losing each
//! other's updates by holding an exclusive `flock` on a sidecar lock file
//! across their read-merge-write cycle, and replace the store with `rename()`
//! so readers never see a half-written file.
use std::collections::BTreeMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
/// Config directory name under `$XDG_CONFIG_HOME`. A literal, not
/// `CARGO_PKG_NAME`: renaming the binary silently abandoning every recorded
/// grant should be an explicit decision made here.
pub(crate) const APP_NAME: &str = "luna";
/// Tri-state access recorded for one directory. `None` = not decided yet,
/// `Some(true)` = approved, `Some(false)` = denied — serialized as JSON
/// `null` / `true` / `false`.
#[derive(Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Debug)]
pub(crate) struct DirAccess {
#[serde(default)]
pub(crate) read: Option<bool>,
#[serde(default)]
pub(crate) write: Option<bool>,
}
impl DirAccess {
pub(crate) fn get(&self, kind: super::policy::AccessKind) -> Option<bool> {
match kind {
super::policy::AccessKind::Read => self.read,
super::policy::AccessKind::Write => self.write,
}
}
}
/// Filesystem grants recorded for one script: canonical directory → tri-state
/// pair. `BTreeMap` for deterministic serialization (stable diffs of
/// access.json).
pub(crate) type DirGrants = BTreeMap<String, DirAccess>;
/// Tri-state access recorded for one network origin, serialized as JSON
/// `null` / `true` / `false`.
#[derive(Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Debug)]
pub(crate) struct OriginAccess {
#[serde(default)]
pub(crate) access: Option<bool>,
}
/// Network grants recorded for one script: origin (`scheme://host[:port]`)
/// → access flag.
pub(crate) type OriginGrants = BTreeMap<String, OriginAccess>;
/// Everything recorded for one script, one section per subsystem. Adding a
/// subsystem is a new field here, not a new file shape.
#[derive(Serialize, Deserialize, Default, PartialEq, Eq, Debug)]
pub(crate) struct ScriptGrants {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub(crate) fs: DirGrants,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub(crate) net: OriginGrants,
}
impl ScriptGrants {
/// True when no section records anything — the whole script entry can go.
fn is_empty(&self) -> bool {
self.fs.is_empty() && self.net.is_empty()
}
/// Keep the file tidy: drop entries that no longer record anything.
fn prune(&mut self) {
self.fs.retain(|_, a| a.read.is_some() || a.write.is_some());
self.net.retain(|_, a| a.access.is_some());
}
}
#[derive(Serialize, Deserialize)]
pub(crate) struct AccessStore {
#[serde(default = "version_default")]
pub(crate) version: u32,
#[serde(default)]
pub(crate) scripts: BTreeMap<String, ScriptGrants>,
}
fn version_default() -> u32 {
1
}
impl Default for AccessStore {
fn default() -> Self {
AccessStore { version: 1, scripts: BTreeMap::new() }
}
}
/// `$XDG_CONFIG_HOME/luna/access.json`, falling back to
/// `~/.config/luna/access.json`.
/// Per the XDG spec a non-absolute `$XDG_CONFIG_HOME` is ignored. `None` when
/// no absolute base can be found — permissions then live for the session only.
pub(crate) fn default_store_path() -> Option<PathBuf> {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|p| p.is_absolute())
.or_else(|| {
std::env::var_os("HOME")
.map(|h| PathBuf::from(h).join(".config"))
.filter(|p| p.is_absolute())
})?;
Some(base.join(APP_NAME).join("access.json"))
}
/// Read the whole store. Missing file → empty store; unreadable or corrupt →
/// warn and treat as empty (a later [`persist`] moves a corrupt file aside
/// before overwriting, preserving whatever the user had).
fn read_store(path: &Path) -> AccessStore {
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return AccessStore::default(),
Err(e) => {
log::warn!("cannot read {}: {e}", path.display());
return AccessStore::default();
}
};
match serde_json::from_slice(&bytes) {
Ok(store) => store,
Err(e) => {
log::warn!("{} is corrupt ({e}); ignoring it", path.display());
AccessStore::default()
}
}
}
/// The grants recorded for one script (empty if none). Startup snapshot;
/// takes no lock — the persist functions' atomic rename guarantees a complete
/// file.
pub(crate) fn load_for_script(store_path: &Path, script_key: &str) -> ScriptGrants {
read_store(store_path).scripts.remove(script_key).unwrap_or_default()
}
/// Record a directory decision: merge `update`'s decided flags (only the
/// `Some` ones) into the `fs` entry for (`script_key`, `dir_key`) and rewrite
/// the file.
pub(crate) fn persist_fs(
store_path: &Path,
script_key: &str,
dir_key: &str,
update: DirAccess,
) -> std::io::Result<()> {
with_store(store_path, |store| {
let entry = store
.scripts
.entry(script_key.to_string())
.or_default()
.fs
.entry(dir_key.to_string())
.or_default();
if update.read.is_some() {
entry.read = update.read;
}
if update.write.is_some() {
entry.write = update.write;
}
})
}
/// Record an origin decision: set the `net` access flag for
/// (`script_key`, `origin`) and rewrite the file.
pub(crate) fn persist_net(
store_path: &Path,
script_key: &str,
origin: &str,
allowed: bool,
) -> std::io::Result<()> {
with_store(store_path, |store| {
store
.scripts
.entry(script_key.to_string())
.or_default()
.net
.insert(origin.to_string(), OriginAccess { access: Some(allowed) });
})
}
/// The shared read-merge-write cycle behind the persist functions: run
/// `update` on the freshly-read store, prune, and rewrite the file.
///
/// Holds an exclusive `flock` on `access.json.lock` across the whole
/// read-merge-write so a concurrently running script can't lose this update.
/// The lock file is separate from the store on purpose: the final `rename`
/// replaces the store's inode, so a lock on the store itself would not exclude
/// a process that opened it just before the rename.
fn with_store(store_path: &Path, update: impl FnOnce(&mut AccessStore)) -> std::io::Result<()> {
let config_dir = store_path.parent().unwrap_or(Path::new("/"));
std::fs::create_dir_all(config_dir)?;
let lock_file = std::fs::File::options()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(store_path.with_extension("json.lock"))?;
rustix::fs::flock(&lock_file, rustix::fs::FlockOperation::LockExclusive)?;
// Re-read fresh under the lock: the startup snapshot (or a previous
// in-process copy) may be stale by now.
let mut store = match std::fs::read(store_path) {
Ok(bytes) => match serde_json::from_slice(&bytes) {
Ok(store) => store,
Err(e) => {
// Corrupt: move it aside instead of silently overwriting
// whatever the user had in there.
let aside = store_path.with_extension("json.corrupt");
log::warn!(
"{} is corrupt ({e}); moving it to {}",
store_path.display(),
aside.display()
);
std::fs::rename(store_path, &aside)?;
AccessStore::default()
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => AccessStore::default(),
Err(e) => return Err(e),
};
update(&mut store);
store.scripts.retain(|_, g| {
g.prune();
!g.is_empty()
});
// Write-to-temp + rename: readers see the old or the new file, never a
// partial one, even across a crash mid-write.
let tmp = store_path.with_extension(format!("json.tmp.{}", std::process::id()));
let mut file = std::fs::File::create(&tmp)?;
file.write_all(&serde_json::to_vec_pretty(&store)?)?;
file.write_all(b"\n")?;
file.sync_all()?;
std::fs::rename(&tmp, store_path)?;
Ok(())
// lock_file drops here, releasing the flock.
}
#[cfg(test)]
mod tests {
use super::super::policy::AccessKind;
use super::*;
fn store_path(dir: &tempfile::TempDir) -> PathBuf {
dir.path().join("cfg").join("access.json")
}
#[test]
fn tri_state_round_trip() {
let json = r#"{
"version": 1,
"scripts": {
"/s/deploy.lua": {
"fs": {
"/data": { "read": true, "write": true },
"/etc": { "read": true, "write": false },
"/secrets": { "read": null, "write": false }
},
"net": {
"https://api.example.com": { "access": true },
"http://insecure.example.com:8090": { "access": false },
"https://undecided.example.com": { "access": null }
}
}
}
}"#;
let store: AccessStore = serde_json::from_str(json).unwrap();
let grants = &store.scripts["/s/deploy.lua"];
assert_eq!(grants.fs["/data"], DirAccess { read: Some(true), write: Some(true) });
assert_eq!(grants.fs["/etc"], DirAccess { read: Some(true), write: Some(false) });
assert_eq!(grants.fs["/secrets"], DirAccess { read: None, write: Some(false) });
assert_eq!(grants.net["https://api.example.com"].access, Some(true));
assert_eq!(grants.net["http://insecure.example.com:8090"].access, Some(false));
assert_eq!(grants.net["https://undecided.example.com"].access, None);
// null and absent both mean "unknown"
let sparse: DirAccess = serde_json::from_str(r#"{ "write": true }"#).unwrap();
assert_eq!(sparse, DirAccess { read: None, write: Some(true) });
// and null survives serialization (the file must distinguish it)
let out = serde_json::to_string(&grants.fs["/secrets"]).unwrap();
assert!(out.contains("\"read\":null"), "{out}");
// round trip
let again: AccessStore =
serde_json::from_str(&serde_json::to_string(&store).unwrap()).unwrap();
assert_eq!(again.scripts, store.scripts);
}
#[test]
fn dir_access_get() {
let a = DirAccess { read: Some(true), write: Some(false) };
assert_eq!(a.get(AccessKind::Read), Some(true));
assert_eq!(a.get(AccessKind::Write), Some(false));
}
#[test]
fn persist_creates_dir_and_merges_without_clobbering() {
let tmp = tempfile::tempdir().unwrap();
let path = store_path(&tmp); // parent "cfg" does not exist yet
persist_fs(&path, "/s/a.lua", "/data", DirAccess { read: Some(true), write: None })
.unwrap();
persist_fs(&path, "/s/a.lua", "/data", DirAccess { read: None, write: Some(false) })
.unwrap();
// the net section merges alongside, not instead
persist_net(&path, "/s/a.lua", "https://api.example.com", true).unwrap();
persist_net(&path, "/s/a.lua", "http://other.example.com:8090", false).unwrap();
let grants = load_for_script(&path, "/s/a.lua");
assert_eq!(grants.fs["/data"], DirAccess { read: Some(true), write: Some(false) });
assert_eq!(grants.net["https://api.example.com"].access, Some(true));
assert_eq!(grants.net["http://other.example.com:8090"].access, Some(false));
// a second script's grants are separate
assert!(load_for_script(&path, "/s/b.lua").is_empty());
}
#[test]
fn persist_net_overwrites_a_previous_answer() {
let tmp = tempfile::tempdir().unwrap();
let path = store_path(&tmp);
persist_net(&path, "/s/a.lua", "https://api.example.com", false).unwrap();
persist_net(&path, "/s/a.lua", "https://api.example.com", true).unwrap();
let grants = load_for_script(&path, "/s/a.lua");
assert_eq!(grants.net["https://api.example.com"].access, Some(true));
assert_eq!(grants.net.len(), 1);
}
#[test]
fn persist_prunes_undecided_entries() {
let tmp = tempfile::tempdir().unwrap();
let path = store_path(&tmp);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
// Hand-written file with a null-null entry (as a user might leave it).
std::fs::write(
&path,
r#"{ "version": 1, "scripts": {
"/s/a.lua": {
"fs": { "/dead": { "read": null, "write": null } },
"net": { "https://dead.example.com": { "access": null } }
},
"/s/gone.lua": { "fs": {} }
} }"#,
)
.unwrap();
persist_fs(&path, "/s/a.lua", "/data", DirAccess { read: Some(true), write: None })
.unwrap();
let store = read_store(&path);
assert!(!store.scripts["/s/a.lua"].fs.contains_key("/dead"));
assert!(store.scripts["/s/a.lua"].net.is_empty());
assert!(!store.scripts.contains_key("/s/gone.lua"));
assert_eq!(store.scripts["/s/a.lua"].fs["/data"].read, Some(true));
}
#[test]
fn persist_moves_corrupt_file_aside() {
let tmp = tempfile::tempdir().unwrap();
let path = store_path(&tmp);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "{ not json").unwrap();
persist_fs(&path, "/s/a.lua", "/data", DirAccess { read: Some(true), write: None })
.unwrap();
assert_eq!(
std::fs::read_to_string(path.with_extension("json.corrupt")).unwrap(),
"{ not json"
);
assert_eq!(load_for_script(&path, "/s/a.lua").fs["/data"].read, Some(true));
}
#[test]
fn missing_or_corrupt_reads_as_empty() {
let tmp = tempfile::tempdir().unwrap();
let path = store_path(&tmp);
assert!(load_for_script(&path, "/s/a.lua").is_empty());
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "]]]").unwrap();
assert!(load_for_script(&path, "/s/a.lua").is_empty());
}
#[test]
fn concurrent_persists_do_not_lose_updates() {
let tmp = tempfile::tempdir().unwrap();
let path = store_path(&tmp);
let threads: Vec<_> = (0..8)
.map(|i| {
let path = path.clone();
std::thread::spawn(move || {
for j in 0..10 {
persist_fs(
&path,
"/s/a.lua",
&format!("/dir-{i}-{j}"),
DirAccess { read: Some(true), write: None },
)
.unwrap();
persist_net(&path, "/s/a.lua", &format!("https://h{i}-{j}.example"), true)
.unwrap();
}
})
})
.collect();
for t in threads {
t.join().unwrap();
}
// every one of the 80+80 grants survived the concurrent
// read-merge-writes, across both sections
let grants = load_for_script(&path, "/s/a.lua");
assert_eq!(grants.fs.len(), 80);
assert_eq!(grants.net.len(), 80);
}
#[test]
fn default_store_path_respects_xdg_rules() {
// Can't mutate the process environment safely in a threaded test
// runner; assert only on the shape of whatever the real env yields.
if let Some(p) = default_store_path() {
assert!(p.is_absolute());
assert!(p.ends_with(format!("{APP_NAME}/access.json")));
}
}
}

@ -1,222 +0,0 @@
//! A sandboxed `require` for loading Lua modules from files.
//!
//! Module names use stock Lua dot notation (`require "db.migrations"`) and are
//! resolved against a fixed list of root directories — the main script's real
//! directory (the CWD in the REPL) followed by the entries of
//! `$LUNA_INCLUDE_PATHS` — through the standard `?.lua` / `?/init.lua` templates.
//! Because a name may only contain identifier-like segments, a module can
//! never name a file outside the roots.
//!
//! Each file is executed once and its return value cached by canonical path,
//! so repeated requires — even under different names or through symlinks —
//! return the same value.
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::rc::Rc;
use mlua::prelude::{LuaResult, LuaTable, LuaValue};
use mlua::Lua;
/// Globals pre-seeded into the module cache, so `require "http"` returns the
/// same table as the global: scripts read more portably and LSPs get an
/// anchor for the stdlib. Covers the project stdlib plus the stock libraries
/// the sandbox loads.
const BUILTINS: &[&str] = &[
"utils",
"log",
"sqlite",
"http",
"fs",
"task",
"tui",
"coroutine",
"math",
"os",
"string",
"table",
"utf8",
];
/// Strip a leading UTF-8 BOM and a shebang line from Lua source, as stock Lua
/// does. The shebang's newline is kept so error messages still report correct
/// line numbers. Used for the main script and for every required file.
pub(crate) fn prepare_chunk(source: &[u8]) -> &[u8] {
let source = source.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(source);
if source.first() == Some(&b'#') {
let nl = source.iter().position(|&b| b == b'\n').unwrap_or(source.len());
&source[nl..]
} else {
source
}
}
/// Install the global `require` function, searching `roots` in order.
pub(crate) fn install(lua: &Lua, roots: Vec<PathBuf>) -> LuaResult<()> {
// Cache of loaded modules, held Lua-side so the values stay GC-anchored.
// Built-ins are keyed by name, file modules by canonical path; the key
// spaces cannot collide because canonical paths are absolute.
let loaded = lua.create_table()?;
for name in BUILTINS {
let value: LuaValue = lua.globals().get(*name)?;
if !value.is_nil() {
loaded.set(*name, value)?;
}
}
let roots = Rc::new(roots);
// Module name -> canonical path it resolved to; repeated requires of the
// same name skip the filesystem probing.
let resolved: Rc<RefCell<HashMap<String, String>>> = Rc::default();
// Canonical paths of modules currently executing, to catch require cycles.
let in_flight: Rc<RefCell<HashSet<String>>> = Rc::default();
let require = lua.create_async_function(move |lua, name: String| {
let loaded = loaded.clone();
let roots = roots.clone();
let resolved = resolved.clone();
let in_flight = in_flight.clone();
async move { require_impl(lua, name, loaded, roots, resolved, in_flight).await }
})?;
lua.globals().set("require", require)
}
async fn require_impl(
lua: Lua,
name: String,
loaded: LuaTable,
roots: Rc<Vec<PathBuf>>,
resolved: Rc<RefCell<HashMap<String, String>>>,
in_flight: Rc<RefCell<HashSet<String>>>,
) -> LuaResult<(LuaValue, Option<String>)> {
// Built-in module?
let builtin: LuaValue = loaded.get(name.as_str())?;
if !builtin.is_nil() {
return Ok((builtin, None));
}
// A name that already resolved to a loaded file?
if let Some(key) = resolved.borrow().get(&name).cloned() {
let value: LuaValue = loaded.get(key.as_str())?;
if !value.is_nil() {
return Ok((value, Some(key)));
}
}
let Some(rel) = module_relpath(&name) else {
return Err(mlua::Error::runtime(format!(
"invalid module name '{name}' (expected dot-separated segments of [A-Za-z0-9_-], like 'dir.mod')"
)));
};
// Probe every root so a hit shadowed by an earlier one can be warned about.
let mut hits: Vec<PathBuf> = Vec::new();
let mut misses: Vec<PathBuf> = Vec::new();
for root in roots.iter() {
let base = root.join(&rel);
for candidate in [base.with_extension("lua"), base.join("init.lua")] {
match tokio::fs::metadata(&candidate).await {
Ok(meta) if meta.is_file() => hits.push(candidate),
_ => misses.push(candidate),
}
}
}
let Some(found) = hits.first() else {
let mut msg = format!("module '{name}' not found:");
for miss in &misses {
msg.push_str(&format!("\n\tno file '{}'", miss.display()));
}
return Err(mlua::Error::runtime(msg));
};
for shadowed in &hits[1..] {
log::warn!(
"require '{name}': using '{}', ignoring shadowed '{}'",
found.display(),
shadowed.display()
);
}
let real = tokio::fs::canonicalize(found).await.map_err(|e| {
mlua::Error::runtime(format!(
"require '{name}': cannot resolve '{}': {e}",
found.display()
))
})?;
let key = real.display().to_string();
// The same file may already be loaded under another name or via a symlink.
let value: LuaValue = loaded.get(key.as_str())?;
if !value.is_nil() {
resolved.borrow_mut().insert(name, key.clone());
return Ok((value, Some(key)));
}
if !in_flight.borrow_mut().insert(key.clone()) {
return Err(mlua::Error::runtime(format!(
"circular require of module '{name}' ('{key}')"
)));
}
// Clears the in-flight marker even when the load errors or is cancelled,
// so a failed module can be required again.
let _guard = InFlightGuard {
set: in_flight.clone(),
key: key.clone(),
};
let source = tokio::fs::read(&real)
.await
.map_err(|e| mlua::Error::runtime(format!("require '{name}': cannot read '{key}': {e}")))?;
let value: LuaValue = lua
.load(prepare_chunk(&source))
// "@" marks the chunk name as a file path in error messages / tracebacks
.set_name(format!("@{key}"))
// like stock Lua, the chunk receives the module name and file path as `...`
.call_async((name.as_str(), key.as_str()))
.await?;
// A module that returns nothing is cached as `true`, as stock require does.
let value = if value.is_nil() {
LuaValue::Boolean(true)
} else {
value
};
loaded.set(key.as_str(), value.clone())?;
resolved.borrow_mut().insert(name, key.clone());
Ok((value, Some(key)))
}
/// Convert a dot-separated module name into a relative path, or `None` when
/// the name is malformed. Restricting segments to identifier-like characters
/// is what keeps `require` inside its roots: `..`, `/` and absolute paths are
/// unrepresentable.
fn module_relpath(name: &str) -> Option<PathBuf> {
if name.is_empty() {
return None;
}
let mut rel = PathBuf::new();
for segment in name.split('.') {
let ok = !segment.is_empty()
&& segment
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-');
if !ok {
return None;
}
rel.push(segment);
}
Some(rel)
}
struct InFlightGuard {
set: Rc<RefCell<HashSet<String>>>,
key: String,
}
impl Drop for InFlightGuard {
fn drop(&mut self) {
self.set.borrow_mut().remove(&self.key);
}
}

@ -1,4 +1,3 @@
use std::path::Path;
use std::sync::{Arc, Mutex};
use mlua::prelude::LuaResult;
@ -131,16 +130,7 @@ fn lua_value_to_sqlite(value: LuaValue) -> LuaResult<Value> {
LuaValue::Boolean(b) => Ok(Value::Integer(b as i64)),
LuaValue::Integer(i) => Ok(Value::Integer(i)),
LuaValue::Number(f) => Ok(Value::Real(f)),
// Lua strings are byte strings. Valid UTF-8 binds as TEXT; anything else
// (raw bytes, embedded NULs) binds as a BLOB so binary data round-trips
// instead of failing the UTF-8 conversion.
LuaValue::String(s) => {
let bytes = s.as_bytes();
match std::str::from_utf8(&bytes) {
Ok(text) => Ok(Value::Text(text.to_owned())),
Err(_) => Ok(Value::Blob(bytes.to_vec())),
}
}
LuaValue::String(s) => Ok(Value::Text(s.to_str()?.to_owned())),
other => Err(mlua::Error::external(format!(
"sqlite: cannot bind value of type '{}'",
other.type_name()
@ -234,37 +224,11 @@ fn rows_to_lua(lua: &Lua, rows: Vec<Record>) -> LuaResult<LuaTable> {
// Blocking workers (run on the tokio blocking pool)
// ---------------------------------------------------------------------------
/// Reject SQL that is empty/comment-only or contains more than one statement.
///
/// `execute`/`query`/`queryOne` compile a single statement via `prepare_cached`,
/// which silently ignores anything after the first — so `execute("A; B")` would
/// run only `A` and drop `B` with no error, and `execute("")` surfaces SQLite's
/// bare "not an error". `Batch` uses SQLite's own tokenizer to count statements
/// (skipping comments/whitespace), giving a clear message instead. Use
/// `executeBatch` to run multiple statements on purpose.
fn ensure_single_statement(conn: &rusqlite::Connection, sql: &str) -> Result<(), DbError> {
let mut batch = rusqlite::Batch::new(conn, sql);
let mut count = 0usize;
while batch.next()?.is_some() {
count += 1;
if count > 1 {
return Err(DbError::Other(
"sqlite: expected a single SQL statement (use executeBatch for multiple)".to_string(),
));
}
}
if count == 0 {
return Err(DbError::Other("sqlite: no SQL statement to run".to_string()));
}
Ok(())
}
/// Run an INSERT/UPDATE/DELETE-style statement, returning rows changed.
async fn run_execute(handle: ConnHandle, sql: String, params: BoundParams) -> Result<usize, DbError> {
tokio::task::spawn_blocking(move || -> Result<usize, DbError> {
let guard = handle.lock().unwrap_or_else(|e| e.into_inner());
let conn = guard.as_ref().ok_or(DbError::Closed)?;
ensure_single_statement(conn, &sql)?;
let mut stmt = conn.prepare_cached(&sql)?;
bind_params(&mut stmt, &params)?;
Ok(stmt.raw_execute()?)
@ -273,24 +237,11 @@ async fn run_execute(handle: ConnHandle, sql: String, params: BoundParams) -> Re
.unwrap_or_else(|e| Err(DbError::Other(format!("sqlite: background task failed: {e}"))))
}
/// Run every statement in `sql` in order, with no parameter binding.
async fn run_execute_batch(handle: ConnHandle, sql: String) -> Result<(), DbError> {
tokio::task::spawn_blocking(move || -> Result<(), DbError> {
let guard = handle.lock().unwrap_or_else(|e| e.into_inner());
let conn = guard.as_ref().ok_or(DbError::Closed)?;
conn.execute_batch(&sql)?;
Ok(())
})
.await
.unwrap_or_else(|e| Err(DbError::Other(format!("sqlite: background task failed: {e}"))))
}
/// Run a SELECT-style statement, materializing every row.
async fn run_query(handle: ConnHandle, sql: String, params: BoundParams) -> Result<Vec<Record>, DbError> {
tokio::task::spawn_blocking(move || -> Result<Vec<Record>, DbError> {
let guard = handle.lock().unwrap_or_else(|e| e.into_inner());
let conn = guard.as_ref().ok_or(DbError::Closed)?;
ensure_single_statement(conn, &sql)?;
let mut stmt = conn.prepare_cached(&sql)?;
// Column names must be captured before the mutable borrows below.
let columns: Vec<String> = stmt.column_names().into_iter().map(String::from).collect();
@ -328,14 +279,6 @@ impl UserData for SqliteConn {
}
});
methods.add_async_method("executeBatch", |_, this, sql: String| {
let handle = this.0.clone();
async move {
run_execute_batch(handle, sql).await.map_err(mlua::Error::external)?;
Ok(())
}
});
methods.add_async_method("query", |lua, this, (sql, params): (String, Option<LuaTable>)| {
let handle = this.0.clone();
let bound = convert_params(params);
@ -358,90 +301,27 @@ impl UserData for SqliteConn {
}
});
// These only read in-memory connection state, but they still contend on
// the same mutex that a running query holds for its whole duration. Doing
// the lock on the blocking pool (not inline) keeps the tokio event loop
// responsive: a `close()` next to a long query no longer freezes every
// other coroutine and timer.
methods.add_async_method("lastInsertRowid", |_, this, ()| {
let handle = this.0.clone();
async move {
tokio::task::spawn_blocking(move || -> Result<i64, DbError> {
let guard = handle.lock().unwrap_or_else(|e| e.into_inner());
Ok(guard.as_ref().ok_or(DbError::Closed)?.last_insert_rowid())
})
.await
.unwrap_or_else(|e| Err(DbError::Other(format!("sqlite: background task failed: {e}"))))
.map_err(mlua::Error::external)
}
// These only read in-memory connection state, so they stay synchronous.
methods.add_method("lastInsertRowid", |_, this, ()| {
let guard = this.0.lock().unwrap_or_else(|e| e.into_inner());
let conn = guard.as_ref().ok_or_else(|| mlua::Error::external(DbError::Closed))?;
Ok(conn.last_insert_rowid())
});
methods.add_async_method("changes", |_, this, ()| {
let handle = this.0.clone();
async move {
tokio::task::spawn_blocking(move || -> Result<i64, DbError> {
let guard = handle.lock().unwrap_or_else(|e| e.into_inner());
Ok(guard.as_ref().ok_or(DbError::Closed)?.changes() as i64)
})
.await
.unwrap_or_else(|e| Err(DbError::Other(format!("sqlite: background task failed: {e}"))))
.map_err(mlua::Error::external)
}
methods.add_method("changes", |_, this, ()| {
let guard = this.0.lock().unwrap_or_else(|e| e.into_inner());
let conn = guard.as_ref().ok_or_else(|| mlua::Error::external(DbError::Closed))?;
Ok(conn.changes() as i64)
});
methods.add_async_method("close", |_, this, ()| {
let handle = this.0.clone();
async move {
methods.add_method("close", |_, this, ()| {
// Dropping the connection releases the SQLite handle; later use errors.
// Done on the blocking pool so it waits behind an in-flight query
// there rather than stalling the event loop.
tokio::task::spawn_blocking(move || {
*handle.lock().unwrap_or_else(|e| e.into_inner()) = None;
})
.await
.map_err(|e| mlua::Error::external(format!("sqlite: background task failed: {e}")))
}
*this.0.lock().unwrap_or_else(|e| e.into_inner()) = None;
Ok(())
});
}
}
// ---------------------------------------------------------------------------
// Path classification for the fs permission system
// ---------------------------------------------------------------------------
/// Databases that live in memory (or in an anonymous temp file SQLite manages
/// itself): `""`, `":memory:"`, and their `file:` URI spellings. rusqlite's
/// default open flags include `SQLITE_OPEN_URI`, so URIs are live syntax.
fn is_memory_db(path: &str) -> bool {
if path.is_empty() || path == ":memory:" {
return true;
}
let Some(rest) = path.strip_prefix("file:") else {
return false;
};
let (file, query) = rest.split_once('?').unwrap_or((rest, ""));
file == ":memory:" || query.split('&').any(|kv| kv == "mode=memory")
}
/// The filesystem path of a file-backed database: strips the `file:` URI
/// scheme, authority and query, leaving what SQLite would open.
fn db_file_path(path: &str) -> &str {
let Some(rest) = path.strip_prefix("file:") else {
return path;
};
let rest = rest.split_once('?').map_or(rest, |(file, _query)| file);
// "file://localhost/p" and "file:///p" carry an (empty) authority;
// "file:/p" and "file:p" do not.
if let Some(after) = rest.strip_prefix("//") {
let (authority, p) = after.split_once('/').unwrap_or((after, ""));
match authority {
"" | "localhost" => return &rest[2 + authority.len()..],
_ => return p, // nonsensical host: best effort
}
}
rest
}
// ---------------------------------------------------------------------------
// Installation
// ---------------------------------------------------------------------------
@ -451,26 +331,8 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> {
sqlite.set(
"connect",
lua.create_async_function(|lua, path: String| async move {
// File-backed databases fall under the fs permission system.
// One combined read/write ask on the database's directory: SQLite
// opens read-write-create and drops WAL/journal files next to the
// file, so a read-only grant would be a lie. Memory-class
// databases touch no user-reachable file and stay exempt (they
// keep working even under --no-fs / --sandbox).
if !is_memory_db(&path) {
let (dir, _target) =
super::fs::write_target("sqlite.connect", Path::new(db_file_path(&path)))
.await?;
super::perm::ensure_dir_access(&lua, "sqlite.connect", dir, super::perm::Wants::READ_WRITE)
.await?;
}
// Opening a file-backed database touches disk, so do it on the
// blocking pool rather than the tokio event loop.
let conn = tokio::task::spawn_blocking(move || rusqlite::Connection::open(&path))
.await
.map_err(|e| mlua::Error::external(format!("sqlite.connect: background task failed: {e}")))?
.map_err(|e| mlua::Error::external(format!("sqlite.connect: {e}")))?;
lua.create_function(|_, path: String| {
let conn = rusqlite::Connection::open(&path).map_err(mlua::Error::external)?;
Ok(SqliteConn(Arc::new(Mutex::new(Some(conn)))))
})?,
)?;
@ -488,29 +350,3 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> {
let methods: LuaTable = sample.metatable()?.get("__index")?;
init.call::<()>(methods)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn memory_db_detection() {
for mem in ["", ":memory:", "file::memory:", "file::memory:?cache=shared", "file:x.db?mode=memory", "file:x.db?cache=shared&mode=memory"] {
assert!(is_memory_db(mem), "{mem:?} should be memory-class");
}
for disk in [":memory:x", "x.db", "/tmp/x.db", "file:x.db", "file:/tmp/x.db?cache=shared", "file:x.db?mode=memoryz"] {
assert!(!is_memory_db(disk), "{disk:?} should be file-backed");
}
}
#[test]
fn db_file_path_strips_uri_wrapping() {
assert_eq!(db_file_path("/tmp/x.db"), "/tmp/x.db");
assert_eq!(db_file_path("x.db"), "x.db");
assert_eq!(db_file_path("file:x.db"), "x.db");
assert_eq!(db_file_path("file:/tmp/x.db"), "/tmp/x.db");
assert_eq!(db_file_path("file:///tmp/x.db"), "/tmp/x.db");
assert_eq!(db_file_path("file://localhost/tmp/x.db"), "/tmp/x.db");
assert_eq!(db_file_path("file:/tmp/x.db?cache=shared&vfs=unix"), "/tmp/x.db");
}
}

@ -2,26 +2,6 @@ use futures::future::join_all;
use mlua::prelude::{LuaResult, LuaValue};
use mlua::{Function, Lua, MultiValue, Variadic};
/// Validate that every variadic argument is a function, returning them as a
/// `Vec<Function>`. Produces a `task.join:`-prefixed error naming the offending
/// argument position and type, instead of mlua's bare conversion message.
fn functions_or_error(args: Variadic<LuaValue>) -> LuaResult<Vec<Function>> {
let mut out = Vec::with_capacity(args.len());
for (i, v) in args.into_iter().enumerate() {
match v {
LuaValue::Function(f) => out.push(f),
other => {
return Err(mlua::Error::external(format!(
"task.join: argument #{} must be a function (got {})",
i + 1,
other.type_name()
)))
}
}
}
Ok(out)
}
/// Proof-of-concept concurrency primitive built on Lua coroutines + tokio.
///
/// `task.join(f1, f2, ...)` runs each function as its own Lua coroutine and
@ -39,8 +19,7 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> {
task.raw_set(
"join",
lua.create_async_function(|lua, args: Variadic<LuaValue>| async move {
let funcs = functions_or_error(args)?;
lua.create_async_function(|lua, funcs: Variadic<Function>| async move {
// Wrap each function in its own coroutine and turn it into a future.
let mut threads = Vec::with_capacity(funcs.len());
for f in funcs.iter() {

@ -1,291 +0,0 @@
//! SymfonyStyle-like message blocks: `[LABEL]`-prefixed colored blocks
//! (`tui.success`, `tui.error`, …) and outlined boxes (`tui.outlineBlock`
//! and its presets).
//!
//! Rendering is pure — `render_block`/`render_outline_block` take the line
//! length and a `colorize` flag and return finished lines — so unit tests pin
//! exact output; the Lua bindings in `mod.rs` supply the real terminal width
//! and color policy and do the printing.
//!
//! Message text is treated as **plain text** here (matching Symfony's blocks,
//! which escape their input): it is word-wrapped and padded by character
//! count, and the single block style is applied to whole lines. Rendering
//! wrapped text that itself contains markup is not supported — compose with
//! `tui.styled` output on your own lines instead.
use super::markup::{self, Style};
/// Blocks never grow wider than this, even on wide terminals (Symfony's
/// MAX_LINE_LENGTH).
pub(super) const MAX_LINE_LENGTH: usize = 120;
pub(super) struct BlockCfg {
pub label: Option<String>,
pub style: Option<Style>,
pub prefix: String,
pub padding: bool,
}
/// Greedy word wrap by character count. Explicit newlines are kept (an empty
/// input line stays an empty output line), runs of spaces collapse, and words
/// longer than the width are hard-broken.
fn wrap(text: &str, width: usize) -> Vec<String> {
let width = width.max(1);
let mut out = Vec::new();
for para in text.replace("\r\n", "\n").split('\n') {
let mut line = String::new();
let mut len = 0usize;
for word in para.split_whitespace() {
let mut rest = word;
let mut rest_len = rest.chars().count();
if len > 0 && len + 1 + rest_len > width {
out.push(std::mem::take(&mut line));
len = 0;
} else if len > 0 {
line.push(' ');
len += 1;
}
while rest_len > width - len {
let cut = rest
.char_indices()
.nth(width - len)
.map(|(i, _)| i)
.unwrap_or(rest.len());
line.push_str(&rest[..cut]);
out.push(std::mem::take(&mut line));
len = 0;
rest = &rest[cut..];
rest_len = rest.chars().count();
}
line.push_str(rest);
len += rest_len;
}
out.push(line);
}
out
}
/// Wrap each message to `width`, with a blank line between messages.
fn wrap_messages(messages: &[String], width: usize) -> Vec<String> {
let mut lines = Vec::new();
for (i, msg) in messages.iter().enumerate() {
if i > 0 {
lines.push(String::new());
}
lines.extend(wrap(msg, width));
}
lines
}
/// A `[LABEL] message` block: wrapped, prefixed, continuation lines indented
/// under the label, every line padded to `line_length` and painted with the
/// block style. Padding (a blank styled line above and below) only applies
/// when colorizing — without a background color it would just be empty lines
/// (same rule as Symfony's `isDecorated()` check).
pub(super) fn render_block(
messages: &[String],
cfg: &BlockCfg,
line_length: usize,
colorize: bool,
) -> Vec<String> {
let label = cfg.label.as_ref().map(|l| format!("[{l}] "));
let prefix_len = cfg.prefix.chars().count();
let indent_len = label.as_ref().map_or(0, |l| l.chars().count());
let text_width = line_length.saturating_sub(prefix_len + indent_len).max(1);
let mut lines = wrap_messages(messages, text_width);
let mut label_line = 0;
if cfg.padding && colorize {
lines.insert(0, String::new());
lines.push(String::new());
label_line = 1;
}
lines
.iter()
.enumerate()
.map(|(i, content)| {
let mut line = String::with_capacity(line_length);
line.push_str(&cfg.prefix);
if let Some(lbl) = &label {
if i == label_line {
line.push_str(lbl);
} else {
line.extend(std::iter::repeat_n(' ', indent_len));
}
}
line.push_str(content);
match (&cfg.style, colorize) {
(Some(style), true) => {
// Pad to full width so the background paints the whole line.
let pad = line_length.saturating_sub(line.chars().count());
line.extend(std::iter::repeat_n(' ', pad));
markup::paint_str(&line, style, true)
}
_ => line.trim_end().to_string(),
}
})
.collect()
}
/// An outlined box: `┌─ Title ───┐` top border, `│ content │` rows, `└───┘`
/// bottom. The style colors the **borders**; content stays plain, which reads
/// well on any terminal color scheme (that is the point of the outline
/// variant).
pub(super) fn render_outline_block(
messages: &[String],
title: Option<&str>,
style: Option<&Style>,
padding: bool,
line_length: usize,
colorize: bool,
) -> Vec<String> {
// Row layout: " │ "(3) + content(line_length - 5) + " │"(2).
let content_width = line_length.saturating_sub(5).max(1);
let mut lines = wrap_messages(messages, content_width);
if padding {
lines.insert(0, String::new());
lines.push(String::new());
}
let paint = |s: &str| match style {
Some(st) => markup::paint_str(s, st, colorize),
None => s.to_string(),
};
let mut out = Vec::new();
out.push(paint(&match title {
Some(t) => format!(
" ┌─ {t} {}┐",
"─".repeat(line_length.saturating_sub(6 + t.chars().count()))
),
None => format!(" ┌{}┐", "─".repeat(line_length.saturating_sub(3))),
}));
for content in &lines {
let pad = content_width.saturating_sub(content.chars().count());
out.push(format!(
"{}{content}{}{}",
paint(" │ "),
" ".repeat(pad),
paint(" │")
));
}
out.push(paint(&format!(" └{}┘", "─".repeat(line_length.saturating_sub(3)))));
out
}
#[cfg(test)]
mod tests {
use super::*;
fn style(spec: &str) -> Style {
markup::resolve_style_spec(spec).unwrap()
}
fn msgs(m: &[&str]) -> Vec<String> {
m.iter().map(|s| s.to_string()).collect()
}
#[test]
fn wrap_basics() {
assert_eq!(wrap("hello world", 20), vec!["hello world"]);
assert_eq!(wrap("hello world", 6), vec!["hello", "world"]);
assert_eq!(wrap("a b c d", 3), vec!["a b", "c d"]);
// Explicit newlines and blank lines are preserved.
assert_eq!(wrap("a\n\nb", 10), vec!["a", "", "b"]);
// Overlong words hard-break.
assert_eq!(wrap("abcdefgh", 3), vec!["abc", "def", "gh"]);
assert_eq!(wrap("xy abcdef", 4), vec!["xy", "abcd", "ef"]);
assert_eq!(wrap("", 10), vec![""]);
}
#[test]
fn block_plain_when_not_colorized() {
let cfg = BlockCfg {
label: Some("OK".into()),
style: Some(style("fg=black;bg=green")),
prefix: " ".into(),
padding: true,
};
// No colors: no padding lines, no escape codes, no trailing spaces.
let lines = render_block(&msgs(&["hello world"]), &cfg, 40, false);
assert_eq!(lines, vec![" [OK] hello world"]);
}
#[test]
fn block_colorized_pads_and_paints_full_width() {
let cfg = BlockCfg {
label: Some("OK".into()),
style: Some(style("fg=black;bg=green")),
prefix: " ".into(),
padding: true,
};
let lines = render_block(&msgs(&["hello world"]), &cfg, 40, true);
assert_eq!(lines.len(), 3, "padding adds a line above and below");
for l in &lines {
assert!(l.starts_with("\x1b[30;42m") && l.ends_with("\x1b[0m"), "{l:?}");
// Full width between the escape codes.
let inner = l.trim_start_matches("\x1b[30;42m").trim_end_matches("\x1b[0m");
assert_eq!(inner.chars().count(), 40, "{inner:?}");
}
assert!(lines[1].contains("[OK] hello world"));
// Padding lines carry no text.
assert!(!lines[0].contains("OK") && !lines[2].contains("OK"));
}
#[test]
fn block_continuation_lines_indent_under_label() {
let cfg = BlockCfg {
label: Some("NOTE".into()),
style: None,
prefix: " ! ".into(),
padding: false,
};
// width 20, prefix 3 + indent 7 => text width 10
let lines = render_block(&msgs(&["aaaa bbbb cccc"]), &cfg, 20, true);
assert_eq!(lines, vec![" ! [NOTE] aaaa bbbb", " ! cccc"]);
}
#[test]
fn block_multiple_messages_are_separated() {
let cfg = BlockCfg { label: None, style: None, prefix: " ".into(), padding: false };
let lines = render_block(&msgs(&["one", "two"]), &cfg, 40, false);
assert_eq!(lines, vec![" one", "", " two"]);
}
#[test]
fn outline_geometry_and_border_painting() {
let lines = render_outline_block(
&msgs(&["hi"]),
Some("Note"),
Some(&style("fg=blue")),
true,
20,
true,
);
// top, blank, content, blank, bottom
assert_eq!(lines.len(), 5);
assert_eq!(lines[0], format!("\x1b[34m ┌─ Note ──────────┐\x1b[0m"));
assert_eq!(lines[4], format!("\x1b[34m └─────────────────┘\x1b[0m"));
// Borders painted, content plain.
assert_eq!(lines[2], format!("\x1b[34m │ \x1b[0mhi{}\x1b[34m │\x1b[0m", " ".repeat(13)));
}
#[test]
fn outline_without_title_or_color() {
let lines =
render_outline_block(&msgs(&["hi"]), None, Some(&style("fg=red")), false, 12, false);
assert_eq!(lines, vec![" ┌─────────┐", " │ hi │", " └─────────┘"]);
for l in &lines {
assert_eq!(l.chars().count(), 12);
assert!(!l.contains('\x1b'));
}
}
#[test]
fn outline_long_title_does_not_underflow() {
let lines = render_outline_block(&msgs(&["x"]), Some("A very long title"), None, false, 10, false);
assert!(lines[0].contains("A very long title"));
}
}

@ -1,886 +0,0 @@
//! Symfony-console-style markup renderer backing `tui.styled`.
//!
//! Input is plain text with HTML-like tags:
//! - built-in style names: `<info>` (bold green), `<error>` (white on red),
//! and the HTML-like `<b>` (bold), `<i>` (italic), `<u>` (underscore),
//! `<s>` (strikethrough);
//! - custom presets registered with `tui.addPreset` (checked first, so they
//! can shadow the built-ins);
//! - attribute specs: `<fg=red;bg=blue;options=bold,underscore>`;
//! - shorthand tokens: `<red;on-white;bold>` — a bare token is an option
//! name, a color (foreground, including `default` and `#hex`), or
//! `on-<color>` (background). Shorthand and `key=value` parts mix freely
//! in one tag (`<red;href=https://…>`);
//! - hyperlinks: `<href=https://…>text</>`;
//! - action tags: cursor movement and clearing emitted in place —
//! `<up>`/`<up=3>` (and `down`/`left`/`right`), absolute `<row=3;col=2>`,
//! `<start>` (column 1), `<home>` (top-left), and
//! `<clear=screen|up|down|line|start|end>`; ops combine in one tag
//! (`<up;clear=line>`). See [`parse_action_spec`].
//!
//! Styles nest: an inner tag overrides only the attributes it sets,
//! everything else is inherited from the enclosing style. `</>`, `</info>`
//! and `</fg=…>` all simply pop the innermost style.
//!
//! A tag that doesn't parse — unknown name, bad color, `<>`, wrong case, a
//! stray `</>` — is emitted as literal text, never an error, matching
//! Symfony's formatter. `\<` (and `\>`) escape the brackets, `\\` a
//! backslash; [`escape`] (`tui.escape`) produces exactly these so untrusted
//! text can be embedded in a format string and come out verbatim.
//!
//! Rendering is pure: `render(input, colorize, presets)` has no I/O and
//! consults no globals, so tests can pin exact byte output. Each text segment
//! is emitted as a self-contained `\x1b[…m…\x1b[0m` run computed from the
//! full effective style, rather than Symfony's reset-and-reapply diffing —
//! simpler, and no stale attributes can leak between segments.
use std::collections::HashMap;
use std::fmt::Write;
use colored::Color;
/// Custom styles registered with `tui.addPreset`, stored per Lua state (as
/// mlua app data) and consulted before the built-in tag names.
#[derive(Default)]
pub(super) struct Presets(pub(super) HashMap<String, Style>);
/// A color attribute in a style spec. `Inherit` (attribute not mentioned in
/// the tag) takes the enclosing style's value when nested; `Default`
/// (`fg=default` / `<default>` / `<on-default>`) explicitly resets to the
/// terminal default, overriding an enclosing color.
#[derive(Clone, Copy, PartialEq, Debug, Default)]
enum ColorSpec {
#[default]
Inherit,
Default,
Set(Color),
}
impl ColorSpec {
fn over(self, base: Self) -> Self {
match self {
ColorSpec::Inherit => base,
other => other,
}
}
}
#[derive(Clone, PartialEq, Debug, Default)]
pub(super) struct Style {
fg: ColorSpec,
bg: ColorSpec,
bold: bool,
italic: bool,
underscore: bool,
blink: bool,
reverse: bool,
conceal: bool,
strikethrough: bool,
href: Option<String>,
}
impl Style {
fn fg(color: Color) -> Self {
Style { fg: ColorSpec::Set(color), ..Default::default() }
}
fn fg_bg(fg: Color, bg: Color) -> Self {
Style { fg: ColorSpec::Set(fg), bg: ColorSpec::Set(bg), ..Default::default() }
}
/// The effective style of `self` nested inside `base`: attributes set here
/// win, everything else is inherited.
fn merge_over(&self, base: &Style) -> Style {
Style {
fg: self.fg.over(base.fg),
bg: self.bg.over(base.bg),
bold: self.bold || base.bold,
italic: self.italic || base.italic,
underscore: self.underscore || base.underscore,
blink: self.blink || base.blink,
reverse: self.reverse || base.reverse,
conceal: self.conceal || base.conceal,
strikethrough: self.strikethrough || base.strikethrough,
href: self.href.clone().or_else(|| base.href.clone()),
}
}
}
/// What an opening tag does. `Style` pushes onto the style stack and applies
/// to the enclosed text. `Action` emits a self-contained escape sequence in
/// place (cursor movement, clearing — see [`parse_action_spec`]) with no
/// stack interaction; like all escape output it is dropped when not
/// colorizing, so piped `tui.styled` output stays free of control bytes.
enum Tag {
Style(Style),
Action(String),
}
/// Resolve a style spec: a custom preset, a built-in name, or an
/// attribute/shorthand spec, in that order. Used for tag bodies and for
/// `style`/preset arguments outside of tag context (`tui.block`,
/// `tui.addPreset`).
pub(super) fn resolve_spec(spec: &str, presets: &Presets) -> Option<Style> {
if let Some(style) = presets.0.get(spec) {
return Some(style.clone());
}
resolve_style_spec(spec)
}
/// [`resolve_spec`] without custom presets — for compile-time style literals.
pub(super) fn resolve_style_spec(spec: &str) -> Option<Style> {
named_style(spec).or_else(|| parse_attr_spec(spec))
}
/// Render one string with a single style (no tag parsing). The block helpers
/// use this to paint fully-assembled lines without the text being
/// re-interpreted as markup.
pub(super) fn paint_str(text: &str, style: &Style, colorize: bool) -> String {
let mut out = String::new();
paint(&mut out, text, Some(style), colorize);
out
}
/// The built-in tag names (case-sensitive, like Symfony's).
fn named_style(name: &str) -> Option<Style> {
Some(match name {
"info" => Style { bold: true, ..Style::fg(Color::Green) },
"error" => Style::fg_bg(Color::White, Color::Red),
// HTML-like shortcuts.
"b" => Style { bold: true, ..Default::default() },
"i" => Style { italic: true, ..Default::default() },
"u" => Style { underscore: true, ..Default::default() },
"s" => Style { strikethrough: true, ..Default::default() },
_ => return None,
})
}
/// Parse the inside of `<...>`. Styles win (so a custom preset can shadow an
/// action name like `up`); a body that is neither a style nor an action spec
/// is `None` — the whole tag (brackets included) stays literal text.
fn resolve_tag(body: &str, presets: &Presets) -> Option<Tag> {
resolve_spec(body, presets)
.map(Tag::Style)
.or_else(|| parse_action_spec(body).map(Tag::Action))
}
/// Parse an action-tag body: `;`-separated cursor/erase operations, each
/// contributing its escape sequence in order. Strict like the style parser —
/// one unknown part rejects the whole tag. Relative moves default to 1
/// (`<up>` == `<up=1>`); `row`/`col` are absolute and require a value.
/// Clears match the `tui.clearLine`/`tui.clearScreen` functions: whole and
/// to-start clears also return the cursor (column 1 / home) so the redraw
/// starts from a known spot; the to-end variants leave it in place.
fn parse_action_spec(body: &str) -> Option<String> {
if body.is_empty() {
return None;
}
let mut out = String::new();
for part in body.split(';') {
match part.split_once('=') {
Some(("clear", area)) => out.push_str(match area {
"screen" => "\x1b[2J\x1b[H",
"up" => "\x1b[1J\x1b[H",
"down" => "\x1b[0J",
"line" => "\x1b[2K\r",
"start" => "\x1b[1K\r",
"end" => "\x1b[0K",
_ => return None,
}),
Some((op, count)) => {
write!(out, "\x1b[{}{}", action_count(count)?, action_final_byte(op)?).unwrap();
}
None => match part {
"start" => out.push('\r'),
"home" => out.push_str("\x1b[H"),
"up" | "down" | "right" | "left" => {
write!(out, "\x1b[1{}", action_final_byte(part).unwrap()).unwrap();
}
_ => return None,
},
}
}
Some(out)
}
/// CSI final byte of a movement op: relative CUU/CUD/CUF/CUB, absolute
/// VPA (`row`) / CHA (`col`).
fn action_final_byte(op: &str) -> Option<char> {
Some(match op {
"up" => 'A',
"down" => 'B',
"right" => 'C',
"left" => 'D',
"row" => 'd',
"col" => 'G',
_ => return None,
})
}
/// A positive decimal count/coordinate (`up=3`, `row=12`); anything else
/// (zero, sign, empty, non-digits) rejects the tag.
fn action_count(s: &str) -> Option<u16> {
if !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse::<u16>().ok().filter(|&n| n > 0)
}
/// Parse an inline spec: `;`-separated parts, each either `key=value`
/// (keys `fg`, `bg`, `options`, `href`) or a bare shorthand token (an option
/// name, a color for the foreground, or `on-<color>` for the background).
/// Strict — no whitespace trimming, unknown keys/values reject the whole tag.
fn parse_attr_spec(body: &str) -> Option<Style> {
if body.is_empty() {
return None;
}
let mut style = Style::default();
for part in body.split(';') {
match part.split_once('=') {
Some(("fg", value)) => style.fg = parse_color(value)?,
Some(("bg", value)) => style.bg = parse_color(value)?,
Some(("options", value)) => {
for opt in value.split(',') {
apply_option(&mut style, opt)?;
}
}
Some(("href", value)) => {
if value.is_empty() {
return None;
}
style.href = Some(value.to_string());
}
Some(_) => return None,
None => apply_shorthand(&mut style, part)?,
}
}
Some(style)
}
fn apply_option(style: &mut Style, opt: &str) -> Option<()> {
match opt {
"bold" => style.bold = true,
"italic" => style.italic = true,
"underscore" => style.underscore = true,
"blink" => style.blink = true,
"reverse" => style.reverse = true,
"conceal" => style.conceal = true,
"strikethrough" => style.strikethrough = true,
_ => return None,
}
Some(())
}
/// A bare shorthand token: an option name (`bold`), a built-in style name
/// (`b`, `info` — merged in), a foreground color (`red`, `default`, `#f0a`),
/// or a background color (`on-red`, `on-default`).
fn apply_shorthand(style: &mut Style, token: &str) -> Option<()> {
if apply_option(style, token).is_some() {
return Some(());
}
if let Some(named) = named_style(token) {
*style = named.merge_over(style);
return Some(());
}
if let Some(bg) = token.strip_prefix("on-") {
style.bg = parse_color(bg)?;
return Some(());
}
style.fg = parse_color(token)?;
Some(())
}
fn parse_color(s: &str) -> Option<ColorSpec> {
Some(ColorSpec::Set(match s {
"default" => return Some(ColorSpec::Default),
"black" => Color::Black,
"red" => Color::Red,
"green" => Color::Green,
"yellow" => Color::Yellow,
"blue" => Color::Blue,
"magenta" => Color::Magenta,
"cyan" => Color::Cyan,
"white" => Color::White,
// Symfony's name for the bright-black ANSI slot.
"gray" | "bright-black" => Color::BrightBlack,
"bright-red" => Color::BrightRed,
"bright-green" => Color::BrightGreen,
"bright-yellow" => Color::BrightYellow,
"bright-blue" => Color::BrightBlue,
"bright-magenta" => Color::BrightMagenta,
"bright-cyan" => Color::BrightCyan,
"bright-white" => Color::BrightWhite,
_ => return parse_hex_color(s),
}))
}
/// `#rrggbb` or `#rgb` (each nibble doubled, CSS-style) → truecolor.
fn parse_hex_color(s: &str) -> Option<ColorSpec> {
let hex = s.strip_prefix('#')?;
let nibble = |i: usize| u8::from_str_radix(&hex[i..i + 1], 16).ok();
let (r, g, b) = match hex.len() {
3 => (nibble(0)? * 17, nibble(1)? * 17, nibble(2)? * 17),
6 => (
u8::from_str_radix(&hex[0..2], 16).ok()?,
u8::from_str_radix(&hex[2..4], 16).ok()?,
u8::from_str_radix(&hex[4..6], 16).ok()?,
),
_ => return None,
};
Some(ColorSpec::Set(Color::TrueColor { r, g, b }))
}
/// SGR parameter list for the full effective style. Empty means the segment
/// needs no escape codes at all (explicit `default` colors emit nothing —
/// every segment is already self-terminated by `\x1b[0m`).
fn sgr_params(style: &Style) -> Vec<String> {
fn color_params(spec: ColorSpec, base: u16, extended_intro: u16) -> Option<String> {
match spec {
ColorSpec::Inherit | ColorSpec::Default => None,
ColorSpec::Set(Color::TrueColor { r, g, b }) => {
Some(format!("{extended_intro};2;{r};{g};{b}"))
}
// Not produced by parse_color today, but the colored enum has it
// (256-color palette); map it correctly rather than panicking.
ColorSpec::Set(Color::AnsiColor(n)) => Some(format!("{extended_intro};5;{n}")),
ColorSpec::Set(c) => {
let n = match c {
Color::Black => 0,
Color::Red => 1,
Color::Green => 2,
Color::Yellow => 3,
Color::Blue => 4,
Color::Magenta => 5,
Color::Cyan => 6,
Color::White => 7,
Color::BrightBlack => 60,
Color::BrightRed => 61,
Color::BrightGreen => 62,
Color::BrightYellow => 63,
Color::BrightBlue => 64,
Color::BrightMagenta => 65,
Color::BrightCyan => 66,
Color::BrightWhite => 67,
Color::TrueColor { .. } | Color::AnsiColor(_) => unreachable!(),
};
Some((base + n).to_string())
}
}
}
let mut params = Vec::new();
if style.bold {
params.push("1".to_string());
}
if style.italic {
params.push("3".to_string());
}
if style.underscore {
params.push("4".to_string());
}
if style.blink {
params.push("5".to_string());
}
if style.reverse {
params.push("7".to_string());
}
if style.conceal {
params.push("8".to_string());
}
if style.strikethrough {
params.push("9".to_string());
}
params.extend(color_params(style.fg, 30, 38));
params.extend(color_params(style.bg, 40, 48));
params
}
/// Append `text` styled with the effective `style` (or plain when `None` /
/// not colorizing / the style has no visible effect).
fn paint(out: &mut String, text: &str, style: Option<&Style>, colorize: bool) {
let style = match style {
Some(s) if colorize => s,
_ => {
out.push_str(text);
return;
}
};
if let Some(url) = &style.href {
write!(out, "\x1b]8;;{url}\x1b\\").unwrap();
}
let params = sgr_params(style);
if params.is_empty() {
out.push_str(text);
} else {
write!(out, "\x1b[{}m{text}\x1b[0m", params.join(";")).unwrap();
}
if style.href.is_some() {
out.push_str("\x1b]8;;\x1b\\");
}
}
enum Event<'a> {
Text(&'a str),
Open(Tag),
/// A valid closing tag; `raw` is kept so a stray close (empty stack) can
/// be re-emitted literally.
Close {
raw: &'a str,
},
}
/// Single pass over the input. Byte-wise scanning is safe: the only bytes
/// acted on (`\`, `<`, `>`, `\n`) are ASCII and never occur inside a UTF-8
/// multi-byte sequence, and all slice cuts land on those ASCII boundaries.
fn tokenize<'a>(input: &'a str, presets: &Presets) -> Vec<Event<'a>> {
fn flush<'a>(events: &mut Vec<Event<'a>>, input: &'a str, from: usize, to: usize) {
if from < to {
events.push(Event::Text(&input[from..to]));
}
}
let bytes = input.as_bytes();
let mut events = Vec::new();
let mut run_start = 0; // start of the current literal text run
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
// `\<` and `\>` escape the bracket, `\\` a backslash; a backslash
// before anything else is ordinary text (stays in the run).
b'\\' if matches!(bytes.get(i + 1), Some(b'<') | Some(b'>') | Some(b'\\')) => {
flush(&mut events, input, run_start, i);
events.push(Event::Text(&input[i + 1..i + 2]));
i += 2;
run_start = i;
}
b'<' => {
// A tag body may not contain `<` or a newline; without a
// closing `>` the bracket is literal (`a < b` stays intact).
let mut end = None;
let mut j = i + 1;
while j < bytes.len() {
match bytes[j] {
b'>' => {
end = Some(j);
break;
}
b'<' | b'\n' => break,
_ => j += 1,
}
}
let Some(end) = end else {
i += 1;
continue;
};
let body = &input[i + 1..end];
let raw = &input[i..=end];
let event = if let Some(rest) = body.strip_prefix('/') {
// `</>`, or any spec that would be a valid *style* opener.
(rest.is_empty() || matches!(resolve_tag(rest, presets), Some(Tag::Style(_))))
.then_some(Event::Close { raw })
} else {
resolve_tag(body, presets).map(Event::Open)
};
match event {
Some(ev) => {
flush(&mut events, input, run_start, i);
events.push(ev);
i = end + 1;
run_start = i;
}
// Unparseable tag: leave the whole `<...>` in the text run.
None => i = end + 1,
}
}
_ => i += 1,
}
}
flush(&mut events, input, run_start, bytes.len());
events
}
/// Backslash-escape the markup metacharacters (`<`, `>`, `\`) so `render`
/// reproduces `text` verbatim — styled only by enclosing tags — no matter
/// what it contains or what markup follows it. Backs `tui.escape`.
pub(super) fn escape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
if matches!(c, '<' | '>' | '\\') {
out.push('\\');
}
out.push(c);
}
out
}
/// Render markup to a string. With `colorize == false` the tags are still
/// parsed and stripped and escapes resolved, but no escape sequences of any
/// kind are emitted — safe for pipes and NO_COLOR.
pub(super) fn render(input: &str, colorize: bool, presets: &Presets) -> String {
let mut out = String::with_capacity(input.len());
// Effective (pre-merged) styles; `last()` is what the current text gets.
let mut stack: Vec<Style> = Vec::new();
// Adjacent text events (e.g. either side of an escaped bracket) are
// buffered so a contiguous same-styled run becomes one SGR segment.
let mut pending = String::new();
for event in tokenize(input, presets) {
if !matches!(event, Event::Text(_)) && !pending.is_empty() {
paint(&mut out, &pending, stack.last(), colorize);
pending.clear();
}
match event {
Event::Text(t) => pending.push_str(t),
Event::Open(Tag::Style(delta)) => {
let effective = match stack.last() {
Some(base) => delta.merge_over(base),
None => delta,
};
stack.push(effective);
}
Event::Open(Tag::Action(seq)) => {
if colorize {
out.push_str(&seq);
}
}
Event::Close { raw } => {
if stack.pop().is_none() {
out.push_str(raw);
}
}
}
}
if !pending.is_empty() {
paint(&mut out, &pending, stack.last(), colorize);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// render() without custom presets.
fn r(input: &str, colorize: bool) -> String {
render(input, colorize, &Presets::default())
}
#[test]
fn plain_text_passes_through() {
assert_eq!(r("hello world", true), "hello world");
assert_eq!(r("hello world", false), "hello world");
}
#[test]
fn builtin_styles() {
assert_eq!(r("<info>x</info>", true), "\x1b[1;32mx\x1b[0m");
assert_eq!(r("<error>x</>", true), "\x1b[37;41mx\x1b[0m");
// Symfony's <comment> and <question> are deliberately NOT built in
// (register with tui.addPreset if wanted) — they stay literal.
assert_eq!(r("<comment>x</comment>", true), "<comment>x</comment>");
assert_eq!(r("<question>x</>", true), "<question>x</>");
}
#[test]
fn html_like_builtins() {
assert_eq!(r("<b>x</b>", true), "\x1b[1mx\x1b[0m");
assert_eq!(r("<i>x</i>", true), "\x1b[3mx\x1b[0m");
assert_eq!(r("<u>x</u>", true), "\x1b[4mx\x1b[0m");
assert_eq!(r("<s>x</s>", true), "\x1b[9mx\x1b[0m");
assert_eq!(r("<b><i>x</i></b>", true), "\x1b[1;3mx\x1b[0m");
}
#[test]
fn inline_spec_combined() {
assert_eq!(
r("<fg=red;bg=blue;options=bold,underscore>x</>", true),
"\x1b[1;4;31;44mx\x1b[0m"
);
}
#[test]
fn all_options() {
assert_eq!(
r("<options=bold,italic,underscore,blink,reverse,conceal,strikethrough>x</>", true),
"\x1b[1;3;4;5;7;8;9mx\x1b[0m"
);
}
#[test]
fn shorthand_tokens() {
assert_eq!(r("<red>x</>", true), "\x1b[31mx\x1b[0m");
assert_eq!(r("<on-white>x</>", true), "\x1b[47mx\x1b[0m");
assert_eq!(r("<red;on-white;bold>x</>", true), "\x1b[1;31;47mx\x1b[0m");
assert_eq!(r("<bright-cyan;strikethrough>x</>", true), "\x1b[9;96mx\x1b[0m");
// Hex colors work as shorthand too, fg and bg.
assert_eq!(r("<#f00>x</>", true), "\x1b[38;2;255;0;0mx\x1b[0m");
assert_eq!(r("<on-#00ff00>x</>", true), "\x1b[48;2;0;255;0mx\x1b[0m");
// Shorthand mixes with key=value parts.
assert_eq!(
r("<red;href=https://e.com>x</>", true),
"\x1b]8;;https://e.com\x1b\\\x1b[31mx\x1b[0m\x1b]8;;\x1b\\"
);
// Built-in names work as tokens too.
assert_eq!(r("<info;bold>x</>", true), "\x1b[1;32mx\x1b[0m");
assert_eq!(r("<b;i>x</>", true), "\x1b[1;3mx\x1b[0m");
// Shorthand closing tags pop like any other.
assert_eq!(r("<red;bold>x</red;bold>", true), "\x1b[1;31mx\x1b[0m");
}
#[test]
fn default_as_color() {
// `default` emits nothing by itself...
assert_eq!(r("<default>x</>", true), "x");
assert_eq!(r("<on-default>x</>", true), "x");
assert_eq!(r("<fg=default;bg=blue>x</>", true), "\x1b[44mx\x1b[0m");
// ...but explicitly resets an inherited color when nested.
assert_eq!(
r("<error>a<default>b</>c</error>", true),
"\x1b[37;41ma\x1b[0m\x1b[41mb\x1b[0m\x1b[37;41mc\x1b[0m"
);
assert_eq!(
r("<error>a<on-default>b</>c</error>", true),
"\x1b[37;41ma\x1b[0m\x1b[37mb\x1b[0m\x1b[37;41mc\x1b[0m"
);
}
#[test]
fn custom_presets() {
let mut presets = Presets::default();
presets.0.insert("keyword".into(), resolve_style_spec("fg=white;bg=magenta").unwrap());
presets.0.insert("info".into(), resolve_style_spec("fg=blue").unwrap());
// A registered preset works as a tag, with both closing forms.
assert_eq!(
render("<keyword>foo</keyword>", true, &presets),
"\x1b[37;45mfoo\x1b[0m"
);
assert_eq!(render("<keyword>foo</>", true, &presets), "\x1b[37;45mfoo\x1b[0m");
// Presets nest and merge like any style.
assert_eq!(
render("<keyword>a<b>b</b></keyword>", true, &presets),
"\x1b[37;45ma\x1b[0m\x1b[1;37;45mb\x1b[0m"
);
// Presets shadow built-ins.
assert_eq!(render("<info>x</>", true, &presets), "\x1b[34mx\x1b[0m");
// Unregistered names are still literal.
assert_eq!(render("<keyward>x", true, &presets), "<keyward>x");
}
#[test]
fn bright_gray_colors() {
assert_eq!(r("<fg=bright-cyan>x</>", true), "\x1b[96mx\x1b[0m");
assert_eq!(r("<fg=gray>x</>", true), "\x1b[90mx\x1b[0m");
assert_eq!(r("<bg=bright-red>x</>", true), "\x1b[101mx\x1b[0m");
}
#[test]
fn hex_colors() {
assert_eq!(r("<fg=#ff0000>x</>", true), "\x1b[38;2;255;0;0mx\x1b[0m");
assert_eq!(r("<fg=#f0a>x</>", true), "\x1b[38;2;255;0;170mx\x1b[0m");
assert_eq!(r("<bg=#00ff00>x</>", true), "\x1b[48;2;0;255;0mx\x1b[0m");
}
#[test]
fn nesting_merges_and_restores() {
// Inner <info> overrides fg only; bg red is inherited. After the
// close, the outer error style is restored.
assert_eq!(
r("<error>a<info>b</info>c</error>", true),
"\x1b[37;41ma\x1b[0m\x1b[1;32;41mb\x1b[0m\x1b[37;41mc\x1b[0m"
);
assert_eq!(
r("<fg=red>a<fg=blue>b</>c</>", true),
"\x1b[31ma\x1b[0m\x1b[34mb\x1b[0m\x1b[31mc\x1b[0m"
);
// Options accumulate.
assert_eq!(r("<options=bold><options=underscore>x</></>", true), "\x1b[1;4mx\x1b[0m");
}
#[test]
fn closing_tag_forms_all_pop() {
assert_eq!(r("<info>x</info>", true), r("<info>x</>", true));
assert_eq!(r("<fg=blue;bg=red>x</fg=blue;bg=red>", true), "\x1b[34;41mx\x1b[0m");
// Any style-parseable close pops (it is not matched against the opener).
assert_eq!(r("<info>x</error>", true), "\x1b[1;32mx\x1b[0m");
}
#[test]
fn invalid_tags_are_literal() {
for s in [
"<foo>x</foo>",
"<Info>x",
"<>",
"<fg=notacolor>x",
"<fg=red;wat=1>x",
"<red;wat>x",
"<on-nope>x",
"<options=sparkle>x",
"<fg = red>x", // strict: no whitespace around `=`
"<fg=>x",
"</garbage>",
"a < b and c > d",
"<info", // unclosed at EOF
] {
assert_eq!(r(s, true), s, "should pass through literally: {s:?}");
}
// `<` never matches across another `<` or a newline.
assert_eq!(r("a <<info>b</>", true), "a <\x1b[1;32mb\x1b[0m");
assert_eq!(r("<foo\n>x", true), "<foo\n>x");
}
#[test]
fn stray_close_is_literal() {
assert_eq!(r("x</>", true), "x</>");
assert_eq!(r("<info>a</></>", true), "\x1b[1;32ma\x1b[0m</>");
}
#[test]
fn escaping() {
assert_eq!(r("\\<info>x", true), "<info>x");
assert_eq!(r("\\<info>x", false), "<info>x");
assert_eq!(r("foo\\<bar", true), "foo<bar");
assert_eq!(r("\\>", true), ">");
// `\\` escapes a backslash, so `\\<info>` is a backslash then a tag...
assert_eq!(r("a\\\\b", true), "a\\b");
assert_eq!(r("\\\\<info>x</>", true), "\\\x1b[1;32mx\x1b[0m");
// ...but a backslash before anything else is ordinary text.
assert_eq!(r("a\\b\\", true), "a\\b\\");
// Escaped bracket inside a styled span stays styled.
assert_eq!(r("<info>\\<x></info>", true), "\x1b[1;32m<x>\x1b[0m");
}
#[test]
fn escape_round_trips() {
for s in [
"plain text",
"<info>x</info>",
"</>",
"a < b > c",
"back\\slash",
"trailing\\",
"\\<pre-escaped>",
"<clear=screen><up=3>",
"<red;href=https://e.com>x</>",
] {
let escaped = escape(s);
// The escaped text renders as itself, in both color modes.
assert_eq!(r(&escaped, true), s, "round trip: {s:?}");
assert_eq!(r(&escaped, false), s, "plain round trip: {s:?}");
// Embedded in a format string it takes the enclosing style and
// never breaks the markup around it — even ending in a backslash.
assert_eq!(
r(&format!("<info>{escaped}</info>!"), true),
format!("\x1b[1;32m{s}\x1b[0m!"),
"embedded: {s:?}"
);
}
assert_eq!(escape(""), "");
}
#[test]
fn href() {
assert_eq!(
r("<href=https://example.com/>link</>", true),
"\x1b]8;;https://example.com/\x1b\\link\x1b]8;;\x1b\\"
);
// Styled text inside the hyperlink span.
assert_eq!(
r("<href=https://e.com><info>x</info></>", true),
"\x1b]8;;https://e.com\x1b\\\x1b[1;32mx\x1b[0m\x1b]8;;\x1b\\"
);
assert_eq!(r("<href=>x", true), "<href=>x");
}
#[test]
fn colorize_false_strips_everything() {
let input =
"<error>a<info>b</info></error> <red;on-white;b>c</> <href=https://e.com>d</> \\<e>";
let out = r(input, false);
assert_eq!(out, "ab c d <e>");
assert!(!out.contains('\x1b'));
}
#[test]
fn empty_and_tag_only_inputs() {
assert_eq!(r("", true), "");
assert_eq!(r("<info></>", true), "");
assert_eq!(r("<info><error></></>", true), "");
}
#[test]
fn unclosed_styles_at_eof_are_fine() {
assert_eq!(r("<info>x", true), "\x1b[1;32mx\x1b[0m");
}
#[test]
fn action_tags() {
assert_eq!(r("<up>", true), "\x1b[1A");
assert_eq!(r("<up=3>", true), "\x1b[3A");
assert_eq!(r("<down>x", true), "\x1b[1Bx");
assert_eq!(r("<left=5;right=2>", true), "\x1b[5D\x1b[2C");
assert_eq!(r("<row=3;col=2>", true), "\x1b[3d\x1b[2G");
assert_eq!(r("<row=5>", true), "\x1b[5d");
assert_eq!(r("<start>", true), "\r");
assert_eq!(r("<home>", true), "\x1b[H");
// Whole and to-start clears also return the cursor (like the
// tui.clearLine/clearScreen functions); to-end clears don't move it.
assert_eq!(r("<clear=screen>", true), "\x1b[2J\x1b[H");
assert_eq!(r("<clear=up>", true), "\x1b[1J\x1b[H");
assert_eq!(r("<clear=down>", true), "\x1b[0J");
assert_eq!(r("<clear=line>", true), "\x1b[2K\r");
assert_eq!(r("<clear=start>", true), "\x1b[1K\r");
assert_eq!(r("<clear=end>", true), "\x1b[0K");
// Ops combine in one tag, emitted in order.
assert_eq!(r("<up;clear=line>", true), "\x1b[1A\x1b[2K\r");
assert_eq!(r("<home;clear=down>", true), "\x1b[H\x1b[0J");
}
#[test]
fn action_tags_do_not_affect_the_style_stack() {
assert_eq!(
r("<info>a<up>b</info>", true),
"\x1b[1;32ma\x1b[0m\x1b[1A\x1b[1;32mb\x1b[0m"
);
// An action is not an opener: `</>` after it pops the *style*.
assert_eq!(r("<info>a<up></>b", true), "\x1b[1;32ma\x1b[0m\x1b[1Ab");
}
#[test]
fn action_tags_stripped_without_colorize() {
let out = r("a<up=3>b<clear=line>c<home>", false);
assert_eq!(out, "abc");
assert!(!out.contains('\x1b') && !out.contains('\r'));
}
#[test]
fn invalid_action_tags_are_literal() {
for s in [
"<up=0>",
"<up=-1>",
"<up=+3>",
"<up=x>",
"<up=>",
"<clear>",
"<clear=all>",
"<clear=>",
"<red;up>", // style and action parts don't mix in one tag
"<up;red>",
"<row>", // absolute ops require a value
"<col>",
"<home=2>",
"<start=2>",
"</up>", // actions have no closing form
] {
assert_eq!(r(s, true), s, "should pass through literally: {s:?}");
}
}
#[test]
fn preset_shadows_action_name() {
let mut presets = Presets::default();
presets.0.insert("up".into(), resolve_style_spec("fg=red").unwrap());
assert_eq!(render("<up>x</>", true, &presets), "\x1b[31mx\x1b[0m");
}
}

@ -1,57 +0,0 @@
//! `tui` — simple terminal interactivity for scripts.
//!
//! The module has two halves, each in its own submodule:
//!
//! - **Prompts** ([`prompts`]): interactive one-liners backed by the
//! `inquire` crate — `tui.confirm`, `tui.promptText`, `tui.promptSelect`,
//! …. Shared shape `(text, default, opts)`; Esc returns `nil`, Ctrl-C
//! raises `"tui.<fn>: interrupted"`, no terminal raises `"not a terminal"`.
//! - **Output** ([`output`]): `tui.styled` markup (engine in [`markup`]),
//! `tui.addPreset`, SymfonyStyle-like message blocks (`tui.success` & co.,
//! rendering in [`blocks`]), and the `tui.raw` / `tui.screenInfo`
//! building blocks.
//!
//! A third piece, **terminal control** ([`term`]), exposes the raw escape
//! sequences behind rich TUIs — cursor movement, clearing, the alternate
//! screen, scroll regions, title/clipboard — as `tui.moveTo`, `tui.altScreen`
//! and friends, with [`cleanup`] restoring the terminal on every exit path.
//!
//! Shared argument parsing (the validated opts table, positional-arg
//! helpers) lives in [`opts`].
mod blocks;
mod markup;
mod opts;
mod output;
mod prompts;
mod term;
// The fs module's permission prompt shares the terminal with tui's prompts
// and must take the same lock. (In test builds fs never opens a real prompt,
// leaving this re-export unused.)
#[cfg_attr(test, allow(unused_imports))]
pub(crate) use prompts::PROMPT_LOCK;
pub(crate) use term::cleanup;
use mlua::prelude::LuaResult;
use mlua::Lua;
/// `mlua::Error::external(format!(...))` — every Lua-facing error in this
/// module goes through this, prefixed `tui.<fn>: …`.
macro_rules! ext_err {
($($arg:tt)*) => { mlua::Error::external(format!($($arg)*)) };
}
use ext_err;
pub(super) fn install(lua: &Lua) -> LuaResult<()> {
// Registry for tui.addPreset, read by tui.styled and the block `style`
// option; per Lua state, so scripts and tests don't leak into each other.
lua.set_app_data(markup::Presets::default());
let tui = lua.create_table()?;
prompts::install(lua, &tui)?;
output::install(lua, &tui)?;
term::install(lua, &tui)?;
lua.globals().raw_set("tui", tui)?;
Ok(())
}

@ -1,328 +0,0 @@
//! Shared argument parsing for the tui Lua bindings: the validated `opts`
//! table view and the positional-argument helpers. All errors follow the
//! project convention — prefixed with `tui.<fn>:` and raised via
//! `mlua::Error::external`.
use std::fmt;
use chrono::NaiveDate;
use mlua::prelude::{LuaResult, LuaTable, LuaValue};
use super::ext_err;
pub(super) const DATE_FMT: &str = "%Y-%m-%d";
/// Validated view over the optional `opts` table. Construction rejects
/// unknown keys so a typo (`placholder`) fails loudly instead of being
/// silently ignored.
#[derive(Debug)]
pub(super) struct Opts {
fname: &'static str,
table: Option<LuaTable>,
}
impl Opts {
pub(super) fn parse(
fname: &'static str,
table: Option<LuaTable>,
allowed: &'static [&'static str],
) -> LuaResult<Opts> {
if let Some(t) = &table {
for pair in t.pairs::<LuaValue, LuaValue>() {
let (key, _) = pair?;
let name = match &key {
LuaValue::String(s) => s.to_string_lossy(),
other => {
return Err(ext_err!(
"{fname}: option keys must be strings (got {})",
other.type_name()
))
}
};
if !allowed.contains(&name.as_str()) {
return Err(ext_err!(
"{fname}: unknown option '{name}' (allowed: {})",
allowed.join(", ")
));
}
}
}
Ok(Opts { fname, table })
}
pub(super) fn fname(&self) -> &'static str {
self.fname
}
pub(super) fn raw(&self, key: &str) -> LuaResult<LuaValue> {
match &self.table {
Some(t) => t.raw_get(key),
None => Ok(LuaValue::Nil),
}
}
pub(super) fn string(&self, key: &str) -> LuaResult<Option<String>> {
match self.raw(key)? {
LuaValue::Nil => Ok(None),
LuaValue::String(s) => Ok(Some(
s.to_str()
.map_err(|_| ext_err!("{}: option '{key}' must be valid UTF-8", self.fname))?
.to_string(),
)),
other => Err(ext_err!(
"{}: option '{key}' must be a string (got {})",
self.fname,
other.type_name()
)),
}
}
pub(super) fn boolean(&self, key: &str) -> LuaResult<Option<bool>> {
match self.raw(key)? {
LuaValue::Nil => Ok(None),
LuaValue::Boolean(b) => Ok(Some(b)),
other => Err(ext_err!(
"{}: option '{key}' must be a boolean (got {})",
self.fname,
other.type_name()
)),
}
}
/// A non-negative whole number.
pub(super) fn count(&self, key: &str) -> LuaResult<Option<usize>> {
let v = self.raw(key)?;
match int_of(&v) {
_ if v.is_nil() => Ok(None),
Some(n) if n >= 0 => Ok(Some(n as usize)),
_ => Err(ext_err!(
"{}: option '{key}' must be a non-negative integer (got {})",
self.fname,
lua_value_brief(&v)
)),
}
}
pub(super) fn number(&self, key: &str) -> LuaResult<Option<f64>> {
match self.raw(key)? {
LuaValue::Nil => Ok(None),
LuaValue::Integer(i) => Ok(Some(i as f64)),
LuaValue::Number(n) => Ok(Some(n)),
other => Err(ext_err!(
"{}: option '{key}' must be a number (got {})",
self.fname,
other.type_name()
)),
}
}
pub(super) fn integer(&self, key: &str) -> LuaResult<Option<i64>> {
let v = self.raw(key)?;
match int_of(&v) {
_ if v.is_nil() => Ok(None),
Some(n) => Ok(Some(n)),
None => Err(ext_err!(
"{}: option '{key}' must be an integer (got {})",
self.fname,
lua_value_brief(&v)
)),
}
}
/// An array of strings; `key[i]` errors name the offending element.
pub(super) fn string_array(&self, key: &str) -> LuaResult<Option<Vec<String>>> {
let t = match self.raw(key)? {
LuaValue::Nil => return Ok(None),
LuaValue::Table(t) => t,
other => {
return Err(ext_err!(
"{}: option '{key}' must be an array of strings (got {})",
self.fname,
other.type_name()
))
}
};
let mut out = Vec::with_capacity(t.raw_len());
for i in 1..=t.raw_len() {
match t.raw_get::<LuaValue>(i)? {
LuaValue::String(s) => out.push(
s.to_str()
.map_err(|_| ext_err!("{}: {key}[{i}] must be valid UTF-8", self.fname))?
.to_string(),
),
other => {
return Err(ext_err!(
"{}: {key}[{i}] must be a string (got {})",
self.fname,
other.type_name()
))
}
}
}
Ok(Some(out))
}
/// A `YYYY-MM-DD` date string.
pub(super) fn date(&self, key: &str) -> LuaResult<Option<NaiveDate>> {
match self.string(key)? {
None => Ok(None),
Some(s) => Ok(Some(parse_date(self.fname, &format!("option '{key}'"), &s)?)),
}
}
}
/// The prompt text (first argument): a required, valid-UTF-8 string.
pub(super) fn text_arg(fname: &str, v: LuaValue) -> LuaResult<String> {
match v {
LuaValue::String(s) => Ok(s
.to_str()
.map_err(|_| ext_err!("{fname}: prompt text must be valid UTF-8"))?
.to_string()),
other => Err(ext_err!(
"{fname}: prompt text must be a string (got {})",
other.type_name()
)),
}
}
/// The positional `default` argument, when the prompt expects a string.
pub(super) fn default_string(fname: &str, v: LuaValue) -> LuaResult<Option<String>> {
match v {
LuaValue::Nil => Ok(None),
LuaValue::String(s) => Ok(Some(
s.to_str()
.map_err(|_| ext_err!("{fname}: default must be valid UTF-8"))?
.to_string(),
)),
other => Err(ext_err!(
"{fname}: default must be a string (got {})",
other.type_name()
)),
}
}
/// Integer value of a Lua number, treating an integral float (`3.0`) as its
/// integer; `None` for everything else (including `3.5`).
pub(super) fn int_of(v: &LuaValue) -> Option<i64> {
match v {
LuaValue::Integer(i) => Some(*i),
LuaValue::Number(n) if n.fract() == 0.0 && n.is_finite() => Some(*n as i64),
_ => None,
}
}
/// `type_name()` alone hides *why* a number was rejected, so show the value
/// for numbers and the type for everything else.
pub(super) fn lua_value_brief(v: &LuaValue) -> String {
match v {
LuaValue::Number(n) => n.to_string(),
other => other.type_name().to_string(),
}
}
pub(super) fn parse_date(fname: &str, what: &str, s: &str) -> LuaResult<NaiveDate> {
NaiveDate::parse_from_str(s, DATE_FMT)
.map_err(|_| ext_err!("{fname}: {what} must be a YYYY-MM-DD string (got '{s}')"))
}
/// `min`/`max` bounds must be ordered when both are present.
pub(super) fn check_bounds<T: PartialOrd + fmt::Display>(
fname: &str,
min: &Option<T>,
max: &Option<T>,
) -> LuaResult<()> {
if let (Some(a), Some(b)) = (min, max)
&& a > b
{
return Err(ext_err!("{fname}: min ({a}) must not be greater than max ({b})"));
}
Ok(())
}
/// Human message for a violated numeric/length bound, phrased by which bounds
/// exist: "must be between 1 and 10" / "must be at least 1" / "must be at most 10".
pub(super) fn bounds_message<T: fmt::Display>(noun: &str, min: &Option<T>, max: &Option<T>) -> String {
match (min, max) {
(Some(a), Some(b)) => format!("{noun} must be between {a} and {b}"),
(Some(a), None) => format!("{noun} must be at least {a}"),
(None, Some(b)) => format!("{noun} must be at most {b}"),
(None, None) => unreachable!("bounds_message called without bounds"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use mlua::Lua;
#[test]
fn parse_date_accepts_iso_only() {
assert_eq!(
parse_date("tui.promptDate", "default", "2026-07-06").unwrap(),
NaiveDate::from_ymd_opt(2026, 7, 6).unwrap()
);
// chrono's %Y-%m-%d is lenient about zero-padding, so "2026-1-5" is
// accepted too — only genuinely malformed inputs are rejected.
assert!(parse_date("tui.promptDate", "default", "2026-1-5").is_ok());
for bad in ["06.07.2026", "not a date", "2026-13-01", "2026-02-30"] {
let err = parse_date("tui.promptDate", "default", bad).unwrap_err();
assert!(err.to_string().contains("YYYY-MM-DD"), "{bad}: {err}");
}
}
#[test]
fn int_of_accepts_integral_floats_only() {
assert_eq!(int_of(&LuaValue::Integer(3)), Some(3));
assert_eq!(int_of(&LuaValue::Number(3.0)), Some(3));
assert_eq!(int_of(&LuaValue::Number(3.5)), None);
assert_eq!(int_of(&LuaValue::Number(f64::INFINITY)), None);
assert_eq!(int_of(&LuaValue::Boolean(true)), None);
}
#[test]
fn bounds_messages() {
assert_eq!(bounds_message("value", &Some(1), &Some(10)), "value must be between 1 and 10");
assert_eq!(bounds_message("value", &Some(1), &None), "value must be at least 1");
assert_eq!(bounds_message::<i64>("value", &None, &Some(10)), "value must be at most 10");
}
#[test]
fn check_bounds_rejects_inverted() {
assert!(check_bounds("tui.promptInt", &Some(1), &Some(10)).is_ok());
assert!(check_bounds("tui.promptInt", &Some(1), &None::<i64>).is_ok());
let err = check_bounds("tui.promptInt", &Some(10), &Some(1)).unwrap_err();
assert!(
err.to_string()
.contains("tui.promptInt: min (10) must not be greater than max (1)"),
"got: {err}"
);
}
#[test]
fn opts_rejects_unknown_keys() {
let lua = Lua::new();
let t = lua.create_table().unwrap();
t.raw_set("placholder", "x").unwrap();
let err = Opts::parse("tui.promptText", Some(t), &["help", "placeholder"]).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("tui.promptText: unknown option 'placholder'"), "got: {msg}");
assert!(msg.contains("allowed: help, placeholder"), "got: {msg}");
}
#[test]
fn opts_typed_getters_check_types() {
let lua = Lua::new();
let t = lua.create_table().unwrap();
t.raw_set("help", 42).unwrap();
t.raw_set("minLen", -1).unwrap();
t.raw_set("multiple", "yes").unwrap();
let opts = Opts::parse("tui.x", Some(t), &["help", "minLen", "multiple"]).unwrap();
assert!(opts.string("help").unwrap_err().to_string().contains("must be a string"));
assert!(opts
.count("minLen")
.unwrap_err()
.to_string()
.contains("must be a non-negative integer"));
assert!(opts.boolean("multiple").unwrap_err().to_string().contains("must be a boolean"));
}
}

@ -1,370 +0,0 @@
//! The output half of the tui module: message blocks (`tui.success` & co.,
//! rendering in [`super::blocks`]), styled markup (`tui.styled` /
//! `tui.addPreset`, engine in [`super::markup`]), and the low-level
//! `tui.raw` / `tui.screenInfo` building blocks.
use std::io::IsTerminal;
use mlua::prelude::{LuaResult, LuaTable, LuaValue};
use mlua::Lua;
use super::opts::Opts;
use super::{blocks, ext_err, markup};
pub(super) fn colorize() -> bool {
colored::control::SHOULD_COLORIZE.should_colorize()
}
/// Width blocks are rendered to: the terminal width capped at
/// [`blocks::MAX_LINE_LENGTH`], or the cap itself when there is no terminal.
fn terminal_line_length() -> usize {
match crossterm::terminal::size() {
Ok((w, _)) if w > 0 => (w as usize).min(blocks::MAX_LINE_LENGTH),
_ => blocks::MAX_LINE_LENGTH,
}
}
/// The block functions' first argument: one message or an array of messages
/// (rendered into the same block, separated by a blank line).
fn messages_arg(fname: &str, v: LuaValue) -> LuaResult<Vec<String>> {
match v {
LuaValue::String(s) => Ok(vec![
s.to_str()
.map_err(|_| ext_err!("{fname}: message must be valid UTF-8"))?
.to_string(),
]),
LuaValue::Table(t) => {
let mut out = Vec::with_capacity(t.raw_len());
for i in 1..=t.raw_len() {
match t.raw_get::<LuaValue>(i)? {
LuaValue::String(s) => out.push(
s.to_str()
.map_err(|_| ext_err!("{fname}: message[{i}] must be valid UTF-8"))?
.to_string(),
),
other => {
return Err(ext_err!(
"{fname}: message[{i}] must be a string (got {})",
other.type_name()
))
}
}
}
if out.is_empty() {
return Err(ext_err!("{fname}: message must not be empty"));
}
Ok(out)
}
other => Err(ext_err!(
"{fname}: message must be a string or an array of strings (got {})",
other.type_name()
)),
}
}
/// The `style` option: same syntax as a markup tag body — a preset or
/// built-in name (`error`), an attribute spec (`fg=black;bg=green`), or
/// shorthand (`black;on-green`). Unlike markup (where a bad tag passes
/// through), a bad *argument* fails loudly.
fn style_opt(lua: &Lua, opts: &Opts, key: &str) -> LuaResult<Option<markup::Style>> {
match opts.string(key)? {
None => Ok(None),
Some(spec) => {
let resolved = match lua.app_data_ref::<markup::Presets>() {
Some(presets) => markup::resolve_spec(&spec, &presets),
None => markup::resolve_style_spec(&spec),
};
resolved
.map(Some)
.ok_or_else(|| ext_err!("{}: invalid style '{spec}'", opts.fname()))
}
}
}
/// The module's single stdout sink. In test builds output goes through
/// `print!` so the libtest harness can capture it — direct `stdout()` writes
/// bypass the capture and trash the test runner's output. In normal builds it
/// writes the bytes directly, ignores errors (a broken pipe must not panic
/// the runtime) and flushes (tui.raw is used for `\r` redraws mid-line).
///
/// Serialized with the prompts via [`PROMPT_LOCK`]: while a prompt (inquire
/// or a permission request) owns the terminal, a concurrent coroutine's
/// output — e.g. a spinner's `<clear=line>` redraw, which would erase the
/// prompt's question line — blocks until the prompt is answered, then
/// resumes.
pub(super) fn write_stdout(bytes: &[u8]) {
let _guard = super::prompts::PROMPT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
#[cfg(test)]
print!("{}", String::from_utf8_lossy(bytes));
#[cfg(not(test))]
{
use std::io::Write;
let mut out = std::io::stdout().lock();
let _ = out.write_all(bytes);
let _ = out.flush();
}
}
/// Print a rendered block surrounded by blank lines (Symfony's block
/// spacing), best-effort like `print`.
fn print_block(lines: &[String]) {
let mut out = String::with_capacity(lines.iter().map(|l| l.len() + 1).sum::<usize>() + 2);
out.push('\n');
for line in lines {
out.push_str(line);
out.push('\n');
}
out.push('\n');
write_stdout(out.as_bytes());
}
/// The `[LABEL]` block presets (Symfony's SymfonyStyle block styles, labels
/// without decoration). `tui.block` is the generic form with no defaults.
struct BlockPreset {
name: &'static str,
fname: &'static str,
label: Option<&'static str>,
style: Option<&'static str>,
prefix: &'static str,
padding: bool,
}
const BLOCK_PRESETS: &[BlockPreset] = &[
BlockPreset { name: "block", fname: "tui.block", label: None, style: None, prefix: " ", padding: false },
BlockPreset { name: "success", fname: "tui.success", label: Some("OK"), style: Some("fg=black;bg=green"), prefix: " ", padding: true },
BlockPreset { name: "error", fname: "tui.error", label: Some("ERROR"), style: Some("fg=white;bg=red"), prefix: " ", padding: true },
BlockPreset { name: "warning", fname: "tui.warning", label: Some("WARNING"), style: Some("fg=black;bg=yellow"), prefix: " ", padding: true },
BlockPreset { name: "caution", fname: "tui.caution", label: Some("CAUTION"), style: Some("fg=white;bg=red"), prefix: " ! ", padding: true },
BlockPreset { name: "info", fname: "tui.info", label: Some("INFO"), style: Some("fg=green"), prefix: " ", padding: false },
BlockPreset { name: "note", fname: "tui.note", label: Some("NOTE"), style: Some("fg=yellow"), prefix: " ! ", padding: false },
BlockPreset { name: "comment", fname: "tui.comment", label: None, style: Some("fg=gray"), prefix: " ", padding: false },
];
/// The outlined-box presets. Titles are plain words (no emoji) and are only
/// fallbacks — `opts.title` overrides them.
struct OutlinePreset {
name: &'static str,
fname: &'static str,
title: Option<&'static str>,
style: Option<&'static str>,
}
const OUTLINE_PRESETS: &[OutlinePreset] = &[
OutlinePreset { name: "outlineBlock", fname: "tui.outlineBlock", title: None, style: None },
OutlinePreset { name: "outlineSuccess", fname: "tui.outlineSuccess", title: Some("Success"), style: Some("fg=green") },
OutlinePreset { name: "outlineError", fname: "tui.outlineError", title: Some("Error"), style: Some("fg=red") },
OutlinePreset { name: "outlineWarning", fname: "tui.outlineWarning", title: Some("Warning"), style: Some("fg=yellow") },
OutlinePreset { name: "outlineNote", fname: "tui.outlineNote", title: Some("Note"), style: Some("fg=blue") },
OutlinePreset { name: "outlineInfo", fname: "tui.outlineInfo", title: Some("Info"), style: Some("fg=green") },
OutlinePreset { name: "outlineCaution", fname: "tui.outlineCaution", title: Some("Caution"), style: Some("fg=red") },
];
/// A preset style spec is a compile-time literal; parsing it cannot fail.
fn preset_style(spec: Option<&str>) -> Option<markup::Style> {
spec.map(|s| markup::resolve_style_spec(s).expect("preset style spec is valid"))
}
/// A `tui.addPreset` name: identifier-like, so it can never collide with
/// attribute specs (which contain `=`) or be mistaken for a closing tag.
fn valid_preset_name(name: &str) -> bool {
let mut chars = name.chars();
chars.next().is_some_and(|c| c.is_ascii_alphabetic())
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
pub(super) fn install(lua: &Lua, tui: &LuaTable) -> LuaResult<()> {
// Message blocks: tui.block + the [LABEL] presets. All print directly to
// stdout (blank-line separated), like Symfony's SymfonyStyle.
for p in BLOCK_PRESETS {
tui.raw_set(
p.name,
lua.create_function(move |lua, (msg, opts): (LuaValue, Option<LuaTable>)| {
let opts = Opts::parse(p.fname, opts, &["label", "style", "prefix", "padding"])?;
let messages = messages_arg(p.fname, msg)?;
let cfg = blocks::BlockCfg {
label: opts.string("label")?.or_else(|| p.label.map(str::to_string)),
style: style_opt(lua, &opts, "style")?.or_else(|| preset_style(p.style)),
prefix: opts.string("prefix")?.unwrap_or_else(|| p.prefix.to_string()),
padding: opts.boolean("padding")?.unwrap_or(p.padding),
};
print_block(&blocks::render_block(
&messages,
&cfg,
terminal_line_length(),
colorize(),
));
Ok(())
})?,
)?;
}
// Outlined boxes: tui.outlineBlock + presets. opts.title overrides the
// preset's fallback title.
for p in OUTLINE_PRESETS {
tui.raw_set(
p.name,
lua.create_function(move |lua, (msg, opts): (LuaValue, Option<LuaTable>)| {
let opts = Opts::parse(p.fname, opts, &["title", "style", "padding"])?;
let messages = messages_arg(p.fname, msg)?;
let title = opts.string("title")?.or_else(|| p.title.map(str::to_string));
let style = style_opt(lua, &opts, "style")?.or_else(|| preset_style(p.style));
let padding = opts.boolean("padding")?.unwrap_or(true);
print_block(&blocks::render_outline_block(
&messages,
title.as_deref(),
style.as_ref(),
padding,
terminal_line_length(),
colorize(),
));
Ok(())
})?,
)?;
}
// tui.styled: render markup to an ANSI string (or plain text when colors
// are off — pipes, NO_COLOR).
tui.raw_set(
"styled",
lua.create_function(|lua, v: LuaValue| {
let s = match v {
LuaValue::String(s) => s,
other => {
return Err(ext_err!(
"tui.styled: expected a string (got {})",
other.type_name()
))
}
};
let s = s
.to_str()
.map_err(|_| ext_err!("tui.styled: input must be valid UTF-8"))?;
let presets = lua
.app_data_ref::<markup::Presets>()
.ok_or_else(|| ext_err!("tui.styled: preset registry missing"))?;
Ok(markup::render(&s, colorize(), &presets))
})?,
)?;
// tui.escape: backslash-escape the markup metacharacters so untrusted
// text (user input, API data) can be embedded in a tui.styled format
// string and come out verbatim.
tui.raw_set(
"escape",
lua.create_function(|_, v: LuaValue| {
let s = match v {
LuaValue::String(s) => s,
other => {
return Err(ext_err!(
"tui.escape: expected a string (got {})",
other.type_name()
))
}
};
let s = s
.to_str()
.map_err(|_| ext_err!("tui.escape: input must be valid UTF-8"))?;
Ok(markup::escape(&s))
})?,
)?;
// tui.addPreset: register a custom markup tag, e.g.
// addPreset("keyword", "fg=white;bg=magenta") makes <keyword>…</keyword>
// work in tui.styled and as a block `style` option. The spec may also
// reference an existing preset or built-in. Re-registering a name
// overwrites it; built-in names can be shadowed.
tui.raw_set(
"addPreset",
lua.create_function(|lua, (name, spec): (LuaValue, LuaValue)| {
const F: &str = "tui.addPreset";
let name = match name {
LuaValue::String(s) => s
.to_str()
.map_err(|_| ext_err!("{F}: name must be valid UTF-8"))?
.to_string(),
other => {
return Err(ext_err!("{F}: name must be a string (got {})", other.type_name()))
}
};
if !valid_preset_name(&name) {
return Err(ext_err!(
"{F}: name must start with a letter and contain only letters, digits, '-' or '_' (got '{name}')"
));
}
let spec = match spec {
LuaValue::String(s) => s
.to_str()
.map_err(|_| ext_err!("{F}: style must be valid UTF-8"))?
.to_string(),
other => {
return Err(ext_err!("{F}: style must be a string (got {})", other.type_name()))
}
};
let mut presets = lua
.app_data_mut::<markup::Presets>()
.ok_or_else(|| ext_err!("{F}: preset registry missing"))?;
let style = markup::resolve_spec(&spec, &presets)
.ok_or_else(|| ext_err!("{F}: invalid style '{spec}'"))?;
presets.0.insert(name, style);
Ok(())
})?,
)?;
// tui.raw: write to stdout with no newline and no separator (io.write
// semantics) and flush — the building block for progress-style output
// (`tui.raw("\rprogress: 50%")`). Values go through tostring; byte
// strings pass through unmodified.
tui.raw_set(
"raw",
lua.create_function(|lua, args: mlua::Variadic<LuaValue>| {
let tostring: mlua::Function = lua.globals().get("tostring")?;
let mut out = Vec::new();
for v in args {
let s = tostring.call::<mlua::String>(v)?;
out.extend_from_slice(&s.as_bytes());
}
write_stdout(&out);
Ok(())
})?,
)?;
// tui.screenInfo: terminal facts for scripts that render their own UI
// (progress bars, right-aligned text, …). width/height are nil when
// there is no terminal to measure.
tui.raw_set(
"screenInfo",
lua.create_function(|lua, ()| {
let t = lua.create_table()?;
// Some ptys report 0x0; treat that as "unknown", like no terminal.
if let Ok((w, h)) = crossterm::terminal::size()
&& w > 0
&& h > 0
{
t.raw_set("width", w)?;
t.raw_set("height", h)?;
}
t.raw_set("tty", std::io::stdout().is_terminal())?;
t.raw_set("colors", colorize())?;
Ok(t)
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preset_name_validation() {
// Names that shadow shorthand tokens ("red", "on-red") are allowed —
// shadowing is the user's explicit, documented choice.
for ok in ["keyword", "a", "warn2", "my-tag", "my_tag", "B", "red", "on-red"] {
assert!(valid_preset_name(ok), "{ok} should be valid");
}
for bad in ["", "2fast", "-x", "fg=red", "a;b", "a b", "žluť"] {
assert!(!valid_preset_name(bad), "{bad} should be invalid");
}
}
}

@ -1,742 +0,0 @@
//! The interactive prompts (`tui.confirm`, `tui.prompt*`), backed by the
//! `inquire` crate.
//!
//! inquire prompts block their thread (crossterm raw mode), so each one runs
//! under `spawn_blocking` like sqlite's I/O — the calling Lua coroutine
//! suspends while timers and sibling coroutines keep running. A process-wide
//! mutex serializes prompts so two coroutines under `task.join` can't fight
//! over the terminal.
//!
//! Each Lua binding validates its arguments into a `Send` config struct
//! *before* spawning, so malformed calls error deterministically even without
//! a terminal; the `run_*` workers then build and drive the inquire prompt on
//! the blocking pool.
use std::fmt;
use std::sync::Mutex;
use chrono::NaiveDate;
use inquire::autocompletion::{Autocomplete, Replacement};
use inquire::error::CustomUserError;
use inquire::list_option::ListOption;
use inquire::validator::Validation;
use inquire::{
Confirm, CustomType, DateSelect, Editor, InquireError, MultiSelect, Password,
PasswordDisplayMode, Select, Text,
};
use mlua::prelude::{LuaResult, LuaTable, LuaValue};
use mlua::Lua;
use super::ext_err;
use super::opts::{
bounds_message, check_bounds, default_string, int_of, lua_value_brief, parse_date, text_arg,
Opts, DATE_FMT,
};
/// Serializes terminal ownership across concurrent Lua coroutines. Locked
/// inside the blocking closure, so a waiting prompt parks a blocking-pool
/// thread, never the tokio event loop. Shared with `term`'s cursor-position
/// query and the fs module's permission prompt, which also read from the
/// terminal (re-exported as `stdlib::tui::PROMPT_LOCK`).
pub(crate) static PROMPT_LOCK: Mutex<()> = Mutex::new(());
/// `Send`-safe error carried out of the blocking prompt worker; the Lua-side
/// wrapper prepends the `tui.<fn>:` prefix.
enum PromptError {
Interrupted,
NotTty,
Other(String),
}
impl fmt::Display for PromptError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PromptError::Interrupted => f.write_str("interrupted"),
PromptError::NotTty => f.write_str("not a terminal"),
PromptError::Other(msg) => f.write_str(msg),
}
}
}
/// Fold an inquire outcome into the module's cancellation model:
/// Esc → `Ok(None)` (surfaces as `nil`), Ctrl-C / no-tty / everything else →
/// error.
fn map_inquire_result<T>(r: Result<T, InquireError>) -> Result<Option<T>, PromptError> {
match r {
Ok(v) => Ok(Some(v)),
Err(InquireError::OperationCanceled) => Ok(None),
Err(InquireError::OperationInterrupted) => Err(PromptError::Interrupted),
Err(InquireError::NotTTY) => Err(PromptError::NotTty),
Err(e) => Err(PromptError::Other(e.to_string())),
}
}
/// Run a blocking prompt worker on the blocking pool, holding the prompt
/// lock, and translate its error with the `tui.<fn>:` prefix.
async fn run_prompt<T, F>(fname: &'static str, worker: F) -> LuaResult<Option<T>>
where
T: Send + 'static,
F: FnOnce() -> Result<Option<T>, PromptError> + Send + 'static,
{
tokio::task::spawn_blocking(move || {
let _guard = PROMPT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
worker()
})
.await
.unwrap_or_else(|e| Err(PromptError::Other(format!("background task failed: {e}"))))
.map_err(|e| ext_err!("{fname}: {e}"))
}
fn find_option_index(fname: &str, options: &[String], value: &str) -> LuaResult<usize> {
options
.iter()
.position(|o| o == value)
.ok_or_else(|| ext_err!("{fname}: default '{value}' is not one of the options"))
}
/// Ensure an editor file extension has its leading dot (`md` → `.md`).
fn normalize_extension(ext: &str) -> String {
if ext.starts_with('.') {
ext.to_string()
} else {
format!(".{ext}")
}
}
/// Static-list autocompleter for `promptText`'s `suggestions` option:
/// case-insensitive substring filter, Tab completes the highlighted entry.
#[derive(Clone)]
struct StaticSuggester(Vec<String>);
impl Autocomplete for StaticSuggester {
fn get_suggestions(&mut self, input: &str) -> Result<Vec<String>, CustomUserError> {
let needle = input.to_lowercase();
Ok(self
.0
.iter()
.filter(|s| s.to_lowercase().contains(&needle))
.cloned()
.collect())
}
fn get_completion(
&mut self,
_input: &str,
highlighted_suggestion: Option<String>,
) -> Result<Replacement, CustomUserError> {
Ok(highlighted_suggestion)
}
}
struct ConfirmCfg {
text: String,
default: Option<bool>,
help: Option<String>,
placeholder: Option<String>,
}
fn run_confirm(cfg: &ConfirmCfg) -> Result<Option<bool>, PromptError> {
let mut p = Confirm::new(&cfg.text);
if let Some(d) = cfg.default {
p = p.with_default(d);
}
if let Some(h) = &cfg.help {
p = p.with_help_message(h);
}
if let Some(ph) = &cfg.placeholder {
p = p.with_placeholder(ph);
}
map_inquire_result(p.prompt())
}
struct TextCfg {
text: String,
initial: Option<String>,
default: Option<String>,
help: Option<String>,
placeholder: Option<String>,
min_len: Option<usize>,
max_len: Option<usize>,
suggestions: Option<Vec<String>>,
page_size: Option<usize>,
}
fn run_text(cfg: &TextCfg) -> Result<Option<String>, PromptError> {
let mut p = Text::new(&cfg.text);
if let Some(v) = &cfg.initial {
p = p.with_initial_value(v);
}
if let Some(v) = &cfg.default {
p = p.with_default(v);
}
if let Some(h) = &cfg.help {
p = p.with_help_message(h);
}
if let Some(ph) = &cfg.placeholder {
p = p.with_placeholder(ph);
}
if cfg.min_len.is_some() || cfg.max_len.is_some() {
let (min, max) = (cfg.min_len, cfg.max_len);
let msg = bounds_message("length", &min, &max);
p = p.with_validator(move |s: &str| {
let len = s.chars().count();
let ok = min.is_none_or(|m| len >= m) && max.is_none_or(|m| len <= m);
if ok {
Ok(Validation::Valid)
} else {
Ok(Validation::Invalid(msg.clone().into()))
}
});
}
if let Some(sugg) = &cfg.suggestions {
p = p.with_autocomplete(StaticSuggester(sugg.clone()));
}
if let Some(n) = cfg.page_size {
p = p.with_page_size(n);
}
map_inquire_result(p.prompt())
}
struct LongtextCfg {
text: String,
predefined: Option<String>,
help: Option<String>,
extension: Option<String>,
editor: Option<String>,
}
fn run_longtext(cfg: &LongtextCfg) -> Result<Option<String>, PromptError> {
let mut p = Editor::new(&cfg.text);
if let Some(v) = &cfg.predefined {
p = p.with_predefined_text(v);
}
if let Some(h) = &cfg.help {
p = p.with_help_message(h);
}
if let Some(e) = &cfg.extension {
p = p.with_file_extension(e);
}
if let Some(cmd) = &cfg.editor {
p = p.with_editor_command(std::ffi::OsStr::new(cmd));
}
map_inquire_result(p.prompt())
}
struct SelectCfg {
text: String,
options: Vec<String>,
help: Option<String>,
page_size: Option<usize>,
vim_mode: Option<bool>,
filter: bool,
// single mode
starting_cursor: Option<usize>,
// multiple mode
default_indices: Vec<usize>,
all_selected: bool,
min_selected: Option<usize>,
max_selected: Option<usize>,
}
fn run_select_single(cfg: &SelectCfg) -> Result<Option<String>, PromptError> {
let mut p = Select::new(&cfg.text, cfg.options.clone());
if let Some(idx) = cfg.starting_cursor {
p = p.with_starting_cursor(idx);
}
if let Some(h) = &cfg.help {
p = p.with_help_message(h);
}
if let Some(n) = cfg.page_size {
p = p.with_page_size(n);
}
if let Some(v) = cfg.vim_mode {
p = p.with_vim_mode(v);
}
if !cfg.filter {
p = p.without_filtering();
}
map_inquire_result(p.prompt())
}
fn run_select_multiple(cfg: &SelectCfg) -> Result<Option<Vec<String>>, PromptError> {
let mut p = MultiSelect::new(&cfg.text, cfg.options.clone());
if !cfg.default_indices.is_empty() {
p = p.with_default(&cfg.default_indices);
}
if cfg.all_selected {
p = p.with_all_selected_by_default();
}
if let Some(h) = &cfg.help {
p = p.with_help_message(h);
}
if let Some(n) = cfg.page_size {
p = p.with_page_size(n);
}
if let Some(v) = cfg.vim_mode {
p = p.with_vim_mode(v);
}
if !cfg.filter {
p = p.without_filtering();
}
if cfg.min_selected.is_some() || cfg.max_selected.is_some() {
let (min, max) = (cfg.min_selected, cfg.max_selected);
let msg = bounds_message("number of selected options", &min, &max);
p = p.with_validator(move |sel: &[ListOption<&String>]| {
let n = sel.len();
let ok = min.is_none_or(|m| n >= m) && max.is_none_or(|m| n <= m);
if ok {
Ok(Validation::Valid)
} else {
Ok(Validation::Invalid(msg.clone().into()))
}
});
}
map_inquire_result(p.prompt())
}
struct PasswordCfg {
text: String,
help: Option<String>,
confirm: bool,
display: PasswordDisplayMode,
toggle: bool,
min_len: Option<usize>,
}
fn run_password(cfg: &PasswordCfg) -> Result<Option<String>, PromptError> {
let mut p = Password::new(&cfg.text).with_display_mode(cfg.display);
if !cfg.confirm {
p = p.without_confirmation();
}
if cfg.toggle {
p = p.with_display_toggle_enabled();
}
if let Some(h) = &cfg.help {
p = p.with_help_message(h);
}
if let Some(min) = cfg.min_len {
let msg = bounds_message("length", &Some(min), &None);
p = p.with_validator(move |s: &str| {
if s.chars().count() >= min {
Ok(Validation::Valid)
} else {
Ok(Validation::Invalid(msg.clone().into()))
}
});
}
map_inquire_result(p.prompt())
}
struct NumberCfg<T> {
text: String,
default: Option<T>,
help: Option<String>,
placeholder: Option<String>,
min: Option<T>,
max: Option<T>,
parse_error: &'static str,
}
fn run_number<T>(cfg: &NumberCfg<T>) -> Result<Option<T>, PromptError>
where
T: Clone + Copy + PartialOrd + fmt::Display + std::str::FromStr + Send + Sync + 'static,
{
let mut p = CustomType::<T>::new(&cfg.text).with_error_message(cfg.parse_error);
if let Some(d) = cfg.default {
p = p.with_default(d);
}
if let Some(h) = &cfg.help {
p = p.with_help_message(h);
}
if let Some(ph) = &cfg.placeholder {
p = p.with_placeholder(ph);
}
if cfg.min.is_some() || cfg.max.is_some() {
let (min, max) = (cfg.min, cfg.max);
let msg = bounds_message("value", &min, &max);
p = p.with_validator(move |v: &T| {
let ok = min.is_none_or(|m| *v >= m) && max.is_none_or(|m| *v <= m);
if ok {
Ok(Validation::Valid)
} else {
Ok(Validation::Invalid(msg.clone().into()))
}
});
}
map_inquire_result(p.prompt())
}
struct DateCfg {
text: String,
default: Option<NaiveDate>,
help: Option<String>,
min: Option<NaiveDate>,
max: Option<NaiveDate>,
week_start: Option<chrono::Weekday>,
}
fn run_date(cfg: &DateCfg) -> Result<Option<NaiveDate>, PromptError> {
let mut p = DateSelect::new(&cfg.text)
// Show the answer as ISO too, not inquire's "Month Day, Year".
.with_formatter(&|d| d.format(DATE_FMT).to_string());
if let Some(d) = cfg.default {
p = p.with_default(d);
}
if let Some(h) = &cfg.help {
p = p.with_help_message(h);
}
if let Some(d) = cfg.min {
p = p.with_min_date(d);
}
if let Some(d) = cfg.max {
p = p.with_max_date(d);
}
if let Some(w) = cfg.week_start {
p = p.with_week_start(w);
}
map_inquire_result(p.prompt())
}
/// Register the prompt functions on the `tui` table. Each binding validates
/// its arguments into the matching `*Cfg` struct above, then drives the
/// blocking worker through [`run_prompt`].
pub(super) fn install(lua: &Lua, tui: &LuaTable) -> LuaResult<()> {
tui.raw_set(
"confirm",
lua.create_async_function(
|_, (text, default, opts): (LuaValue, LuaValue, Option<LuaTable>)| async move {
const F: &str = "tui.confirm";
let opts = Opts::parse(F, opts, &["help", "placeholder"])?;
let cfg = ConfirmCfg {
text: text_arg(F, text)?,
default: match default {
LuaValue::Nil => None,
LuaValue::Boolean(b) => Some(b),
other => {
return Err(ext_err!(
"{F}: default must be a boolean (got {})",
other.type_name()
))
}
},
help: opts.string("help")?,
placeholder: opts.string("placeholder")?,
};
run_prompt(F, move || run_confirm(&cfg)).await
},
)?,
)?;
tui.raw_set(
"promptText",
lua.create_async_function(
|_, (text, default, opts): (LuaValue, LuaValue, Option<LuaTable>)| async move {
const F: &str = "tui.promptText";
let opts = Opts::parse(
F,
opts,
&["help", "placeholder", "default", "minLen", "maxLen", "suggestions", "pageSize"],
)?;
let cfg = TextCfg {
text: text_arg(F, text)?,
initial: default_string(F, default)?,
default: opts.string("default")?,
help: opts.string("help")?,
placeholder: opts.string("placeholder")?,
min_len: opts.count("minLen")?,
max_len: opts.count("maxLen")?,
suggestions: opts.string_array("suggestions")?,
page_size: opts.count("pageSize")?,
};
check_bounds(F, &cfg.min_len, &cfg.max_len)?;
run_prompt(F, move || run_text(&cfg)).await
},
)?,
)?;
tui.raw_set(
"promptLongText",
lua.create_async_function(
|_, (text, default, opts): (LuaValue, LuaValue, Option<LuaTable>)| async move {
const F: &str = "tui.promptLongText";
let opts = Opts::parse(F, opts, &["help", "extension", "editor"])?;
let cfg = LongtextCfg {
text: text_arg(F, text)?,
predefined: default_string(F, default)?,
help: opts.string("help")?,
extension: opts.string("extension")?.map(|e| normalize_extension(&e)),
editor: opts.string("editor")?,
};
run_prompt(F, move || run_longtext(&cfg)).await
},
)?,
)?;
tui.raw_set(
"promptSelect",
lua.create_async_function(
|lua, (text, default, opts): (LuaValue, LuaValue, Option<LuaTable>)| async move {
const F: &str = "tui.promptSelect";
if opts.is_none() {
return Err(ext_err!("{F}: opts table with 'options' is required"));
}
let opts = Opts::parse(
F,
opts,
&[
"options", "multiple", "help", "pageSize", "vimMode", "filter",
"minSelected", "maxSelected", "allSelected",
],
)?;
let options = opts
.string_array("options")?
.ok_or_else(|| ext_err!("{F}: opts table with 'options' is required"))?;
if options.is_empty() {
return Err(ext_err!("{F}: options must not be empty"));
}
let multiple = opts.boolean("multiple")?.unwrap_or(false);
let mut cfg = SelectCfg {
text: text_arg(F, text)?,
help: opts.string("help")?,
page_size: opts.count("pageSize")?,
vim_mode: opts.boolean("vimMode")?,
filter: opts.boolean("filter")?.unwrap_or(true),
starting_cursor: None,
default_indices: Vec::new(),
all_selected: opts.boolean("allSelected")?.unwrap_or(false),
min_selected: opts.count("minSelected")?,
max_selected: opts.count("maxSelected")?,
options,
};
check_bounds(F, &cfg.min_selected, &cfg.max_selected)?;
if !multiple {
for key in ["minSelected", "maxSelected", "allSelected"] {
if !opts.raw(key)?.is_nil() {
return Err(ext_err!("{F}: option '{key}' requires multiple = true"));
}
}
}
// The default is matched against the options *by value*; a
// stale default is a script bug, so it errors rather than
// being silently ignored.
match (multiple, default) {
(_, LuaValue::Nil) => {}
(false, LuaValue::String(s)) => {
let s = s
.to_str()
.map_err(|_| ext_err!("{F}: default must be valid UTF-8"))?;
cfg.starting_cursor = Some(find_option_index(F, &cfg.options, &s)?);
}
(false, other) => {
return Err(ext_err!(
"{F}: default must be a string (got {})",
other.type_name()
))
}
(true, LuaValue::String(s)) => {
let s = s
.to_str()
.map_err(|_| ext_err!("{F}: default must be valid UTF-8"))?;
cfg.default_indices = vec![find_option_index(F, &cfg.options, &s)?];
}
(true, LuaValue::Table(t)) => {
for i in 1..=t.raw_len() {
match t.raw_get::<LuaValue>(i)? {
LuaValue::String(s) => {
let s = s.to_str().map_err(|_| {
ext_err!("{F}: default[{i}] must be valid UTF-8")
})?;
cfg.default_indices
.push(find_option_index(F, &cfg.options, &s)?);
}
other => {
return Err(ext_err!(
"{F}: default[{i}] must be a string (got {})",
other.type_name()
))
}
}
}
}
(true, other) => {
return Err(ext_err!(
"{F}: default must be a string or an array of strings (got {})",
other.type_name()
))
}
}
if multiple {
let chosen = run_prompt(F, move || run_select_multiple(&cfg)).await?;
match chosen {
Some(items) => Ok(LuaValue::Table(lua.create_sequence_from(items)?)),
None => Ok(LuaValue::Nil),
}
} else {
let chosen = run_prompt(F, move || run_select_single(&cfg)).await?;
match chosen {
Some(s) => Ok(LuaValue::String(lua.create_string(&s)?)),
None => Ok(LuaValue::Nil),
}
}
},
)?,
)?;
tui.raw_set(
"promptSecret",
lua.create_async_function(
|_, (text, opts): (LuaValue, Option<LuaTable>)| async move {
const F: &str = "tui.promptSecret";
let opts = Opts::parse(F, opts, &["help", "confirm", "display", "toggle", "minLen"])?;
let cfg = PasswordCfg {
text: text_arg(F, text)?,
help: opts.string("help")?,
confirm: opts.boolean("confirm")?.unwrap_or(false),
display: match opts.string("display")?.as_deref() {
None | Some("masked") => PasswordDisplayMode::Masked,
Some("hidden") => PasswordDisplayMode::Hidden,
Some("full") => PasswordDisplayMode::Full,
Some(other) => {
return Err(ext_err!(
"{F}: option 'display' must be one of 'masked', 'hidden', 'full' (got '{other}')"
))
}
},
toggle: opts.boolean("toggle")?.unwrap_or(false),
min_len: opts.count("minLen")?,
};
run_prompt(F, move || run_password(&cfg)).await
},
)?,
)?;
tui.raw_set(
"promptNumber",
lua.create_async_function(
|_, (text, default, opts): (LuaValue, LuaValue, Option<LuaTable>)| async move {
const F: &str = "tui.promptNumber";
let opts = Opts::parse(F, opts, &["help", "placeholder", "min", "max"])?;
let cfg = NumberCfg::<f64> {
text: text_arg(F, text)?,
default: match &default {
LuaValue::Nil => None,
LuaValue::Integer(i) => Some(*i as f64),
LuaValue::Number(n) => Some(*n),
other => {
return Err(ext_err!(
"{F}: default must be a number (got {})",
other.type_name()
))
}
},
help: opts.string("help")?,
placeholder: opts.string("placeholder")?,
min: opts.number("min")?,
max: opts.number("max")?,
parse_error: "please enter a valid number",
};
check_bounds(F, &cfg.min, &cfg.max)?;
run_prompt(F, move || run_number(&cfg)).await
},
)?,
)?;
tui.raw_set(
"promptInt",
lua.create_async_function(
|_, (text, default, opts): (LuaValue, LuaValue, Option<LuaTable>)| async move {
const F: &str = "tui.promptInt";
let opts = Opts::parse(F, opts, &["help", "placeholder", "min", "max"])?;
let cfg = NumberCfg::<i64> {
text: text_arg(F, text)?,
default: match int_of(&default) {
_ if default.is_nil() => None,
Some(i) => Some(i),
None => {
return Err(ext_err!(
"{F}: default must be an integer (got {})",
lua_value_brief(&default)
))
}
},
help: opts.string("help")?,
placeholder: opts.string("placeholder")?,
min: opts.integer("min")?,
max: opts.integer("max")?,
parse_error: "please enter a whole number",
};
check_bounds(F, &cfg.min, &cfg.max)?;
run_prompt(F, move || run_number(&cfg)).await
},
)?,
)?;
tui.raw_set(
"promptDate",
lua.create_async_function(
|_, (text, default, opts): (LuaValue, LuaValue, Option<LuaTable>)| async move {
const F: &str = "tui.promptDate";
let opts = Opts::parse(F, opts, &["help", "min", "max", "weekStart"])?;
let cfg = DateCfg {
text: text_arg(F, text)?,
default: match default_string(F, default)? {
None => None,
Some(s) => Some(parse_date(F, "default", &s)?),
},
help: opts.string("help")?,
min: opts.date("min")?,
max: opts.date("max")?,
week_start: match opts.string("weekStart")? {
None => None,
Some(s) => Some(s.parse::<chrono::Weekday>().map_err(|_| {
ext_err!("{F}: option 'weekStart' must be a weekday name like 'monday' (got '{s}')")
})?),
},
};
check_bounds(F, &cfg.min, &cfg.max)?;
let picked = run_prompt(F, move || run_date(&cfg)).await?;
Ok(picked.map(|d| d.format(DATE_FMT).to_string()))
},
)?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn find_option_index_matches_by_value() {
let opts = vec!["red".to_string(), "green".to_string()];
assert_eq!(find_option_index("tui.promptSelect", &opts, "green").unwrap(), 1);
let err = find_option_index("tui.promptSelect", &opts, "blue").unwrap_err();
assert!(
err.to_string()
.contains("tui.promptSelect: default 'blue' is not one of the options"),
"got: {err}"
);
}
#[test]
fn normalize_extension_adds_dot() {
assert_eq!(normalize_extension("md"), ".md");
assert_eq!(normalize_extension(".md"), ".md");
}
#[test]
fn static_suggester_filters_case_insensitively() {
let mut s = StaticSuggester(vec!["Apple".into(), "banana".into(), "Cherry".into()]);
assert_eq!(s.get_suggestions("an").unwrap(), vec!["banana".to_string()]);
assert_eq!(
s.get_suggestions("A").unwrap(),
vec!["Apple".to_string(), "banana".to_string()]
);
assert_eq!(s.get_suggestions("").unwrap().len(), 3);
assert_eq!(s.get_suggestions("xyz").unwrap(), Vec::<String>::new());
}
}

@ -1,586 +0,0 @@
//! Terminal control bindings: cursor movement, line/screen clearing, the
//! alternate screen, scroll regions, window title, clipboard and soft reset —
//! the xterm-style escape sequences behind rich TUIs, exposed as plain
//! functions (`tui.moveTo`, `tui.clearLine`, `tui.altScreen`, …).
//!
//! Two rules hold for every function here:
//!
//! - **Arguments are validated first, unconditionally** — a typo fails loudly
//! even when nothing would be emitted (same contract as the prompts).
//! - **Sequences go only to a real terminal.** When stdout is not a tty the
//! functions validate and then do nothing: piped output must never contain
//! control bytes. This is a plain tty check, deliberately not the color
//! heuristics — `NO_COLOR` turns off colors, not cursor control. (Markup
//! *action tags* on the other hand ride inside `tui.styled` strings and
//! follow its color detection; see [`super::markup`].)
//!
//! Stateful operations (alternate screen, hidden cursor, cursor style, scroll
//! region, pushed title) are tracked in a process-wide [`TermState`];
//! [`cleanup`] undoes whatever is still active and runs on every exit path —
//! normal return, script error, and panic (hooked in `main`). A script that
//! forgets `tui.altScreen(false)` cannot wreck the user's terminal.
use std::io::IsTerminal;
use std::sync::{Mutex, MutexGuard};
use base64::Engine;
use crossterm::{cursor, terminal};
use mlua::prelude::{LuaResult, LuaTable, LuaValue};
use mlua::Lua;
use super::opts::{int_of, lua_value_brief, Opts};
use super::output::write_stdout;
use super::{ext_err, prompts};
/// Terminal modes that outlive the call that set them. One instance per
/// process (not per Lua state): there is one terminal, and the cleanup runs
/// from `main` / the panic hook where no Lua state may exist anymore.
struct TermState {
alt_screen: bool,
cursor_hidden: bool,
cursor_styled: bool,
scroll_region: bool,
title_pushed: bool,
}
const CLEAN: TermState = TermState {
alt_screen: false,
cursor_hidden: false,
cursor_styled: false,
scroll_region: false,
title_pushed: false,
};
static STATE: Mutex<TermState> = Mutex::new(CLEAN);
fn state() -> MutexGuard<'static, TermState> {
STATE.lock().unwrap_or_else(|e| e.into_inner())
}
/// Everything needed to undo `st`, in an order that works from any state:
/// scroll region and cursor first (they may belong to the alternate screen),
/// then leave the alternate screen, then pop the title.
fn restore_sequence(st: &TermState) -> String {
let mut out = String::new();
if st.scroll_region {
out.push_str("\x1b[r");
}
if st.cursor_styled {
out.push_str("\x1b[0 q");
}
if st.cursor_hidden {
out.push_str(&seq(cursor::Show));
}
if st.alt_screen {
out.push_str(&seq(terminal::LeaveAlternateScreen));
}
if st.title_pushed {
out.push_str("\x1b[23;2t");
}
out
}
/// Restore any terminal state a script left behind. Called by `main` on every
/// exit path (normal, error, panic hook); idempotent — the tracked state is
/// cleared, so a second call emits nothing.
pub(crate) fn cleanup() {
let mut st = state();
let out = restore_sequence(&st);
*st = CLEAN;
drop(st);
if !out.is_empty() {
write_stdout(out.as_bytes());
}
}
/// The ANSI bytes of a crossterm command (writing to a `String` cannot fail).
fn seq(cmd: impl crossterm::Command) -> String {
let mut s = String::new();
cmd.write_ansi(&mut s).expect("write_ansi to String");
s
}
/// Whether control sequences are emitted at all — see the module docs.
fn active() -> bool {
std::io::stdout().is_terminal()
}
/// Send a fire-and-forget sequence, or drop it when stdout is not a terminal.
fn emit(s: &str) {
if active() {
write_stdout(s.as_bytes());
}
}
/// `lua_value_brief`, but integral values print their value (a rejected `0`
/// or `2` should say so, not "integer").
fn brief(v: &LuaValue) -> String {
match int_of(v) {
Some(n) => n.to_string(),
None => lua_value_brief(v),
}
}
/// A 1-based screen coordinate, or nil. Values beyond u16 are clamped — the
/// terminal clamps to the screen edge anyway.
fn opt_coord(fname: &str, what: &str, v: &LuaValue) -> LuaResult<Option<u16>> {
if v.is_nil() {
return Ok(None);
}
match int_of(v) {
Some(n) if n >= 1 => Ok(Some(n.min(u16::MAX as i64) as u16)),
_ => Err(ext_err!("{fname}: {what} must be a positive integer (got {})", brief(v))),
}
}
/// A relative-move distance; nil counts as 0 (no movement on that axis).
fn opt_delta(fname: &str, what: &str, v: &LuaValue) -> LuaResult<i64> {
if v.is_nil() {
return Ok(0);
}
int_of(v)
.ok_or_else(|| ext_err!("{fname}: {what} must be an integer (got {})", lua_value_brief(v)))
}
/// Magnitude of a delta as the u16 a CSI parameter can carry.
fn magnitude(n: i64) -> u16 {
n.unsigned_abs().min(u16::MAX as u64) as u16
}
/// The shared clear-mode argument: nil/0 = whole, -1 = to start, 1 = to end.
fn clear_mode(fname: &str, v: &LuaValue) -> LuaResult<i64> {
if v.is_nil() {
return Ok(0);
}
match int_of(v) {
Some(m @ -1..=1) => Ok(m),
_ => Err(ext_err!(
"{fname}: mode must be -1 (to start), 0 (whole, default) or 1 (to end), got {}",
brief(v)
)),
}
}
/// A required string argument that is valid UTF-8.
fn string_arg(fname: &str, what: &str, v: LuaValue) -> LuaResult<String> {
match v {
LuaValue::String(s) => Ok(s
.to_str()
.map_err(|_| ext_err!("{fname}: {what} must be valid UTF-8"))?
.to_string()),
other => Err(ext_err!("{fname}: {what} must be a string (got {})", other.type_name())),
}
}
pub(super) fn install(lua: &Lua, tui: &LuaTable) -> LuaResult<()> {
// tui.moveTo(row, col): absolute cursor position, 1-based like the
// terminal itself; nil keeps that axis (CUP / VPA / CHA).
tui.raw_set(
"moveTo",
lua.create_function(|_, (row, col): (LuaValue, LuaValue)| {
const F: &str = "tui.moveTo";
let row = opt_coord(F, "row", &row)?;
let col = opt_coord(F, "col", &col)?;
let s = match (row, col) {
(Some(r), Some(c)) => seq(cursor::MoveTo(c - 1, r - 1)),
(Some(r), None) => seq(cursor::MoveToRow(r - 1)),
(None, Some(c)) => seq(cursor::MoveToColumn(c - 1)),
(None, None) => return Err(ext_err!("{F}: row and col cannot both be nil")),
};
emit(&s);
Ok(())
})?,
)?;
// tui.move(dx, dy): relative move — positive dx right, positive dy down
// (screen coordinates); nil is 0. The terminal clamps at the edges.
tui.raw_set(
"move",
lua.create_function(|_, (dx, dy): (LuaValue, LuaValue)| {
const F: &str = "tui.move";
let dx = opt_delta(F, "dx", &dx)?;
let dy = opt_delta(F, "dy", &dy)?;
let mut s = String::new();
match dx {
1.. => s.push_str(&seq(cursor::MoveRight(magnitude(dx)))),
..0 => s.push_str(&seq(cursor::MoveLeft(magnitude(dx)))),
0 => {}
}
match dy {
1.. => s.push_str(&seq(cursor::MoveDown(magnitude(dy)))),
..0 => s.push_str(&seq(cursor::MoveUp(magnitude(dy)))),
0 => {}
}
emit(&s);
Ok(())
})?,
)?;
// tui.saveCursor() / tui.restoreCursor(): DECSC/DECRC — position, but
// also the SGR state, one slot deep.
tui.raw_set(
"saveCursor",
lua.create_function(|_, ()| {
emit(&seq(cursor::SavePosition));
Ok(())
})?,
)?;
tui.raw_set(
"restoreCursor",
lua.create_function(|_, ()| {
emit(&seq(cursor::RestorePosition));
Ok(())
})?,
)?;
// tui.cursorPos() -> row, col (1-based), or nil off-tty. The DSR-CPR
// round trip reads the terminal's reply from its input, so it runs under
// the prompt lock: a concurrent coroutine's prompt must not eat the reply.
tui.raw_set(
"cursorPos",
lua.create_async_function(|_, ()| async {
if !active() {
return Ok((None, None));
}
let pos = tokio::task::spawn_blocking(|| {
let _guard = prompts::PROMPT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
cursor::position().ok()
})
.await
.ok()
.flatten();
Ok(match pos {
Some((col, row)) => (Some(row as u32 + 1), Some(col as u32 + 1)),
None => (None, None),
})
})?,
)?;
// tui.showCursor(visible): DECTCEM; no argument means show. A hidden
// cursor is tracked and re-shown on exit.
tui.raw_set(
"showCursor",
lua.create_function(|_, v: LuaValue| {
const F: &str = "tui.showCursor";
let show = match v {
LuaValue::Nil => true,
LuaValue::Boolean(b) => b,
other => {
return Err(ext_err!(
"{F}: expected a boolean or nil (got {})",
other.type_name()
))
}
};
if active() {
write_stdout(seq_of_visibility(show).as_bytes());
state().cursor_hidden = !show;
}
Ok(())
})?,
)?;
// tui.cursorStyle("block"|"underline"|"bar", {blink=}): DECSCUSR; no
// style resets to the terminal default. A changed style is restored on
// exit.
tui.raw_set(
"cursorStyle",
lua.create_function(|_, (style, opts): (LuaValue, Option<LuaTable>)| {
const F: &str = "tui.cursorStyle";
let opts = Opts::parse(F, opts, &["blink"])?;
let blink = opts.boolean("blink")?.unwrap_or(false);
let style = match style {
LuaValue::Nil => None,
LuaValue::String(s) => Some(
s.to_str()
.map_err(|_| ext_err!("{F}: style must be valid UTF-8"))?
.to_string(),
),
other => {
return Err(ext_err!("{F}: style must be a string (got {})", other.type_name()))
}
};
use cursor::SetCursorStyle::*;
let cmd = match (style.as_deref(), blink) {
(None, false) => DefaultUserShape,
(None, true) => {
return Err(ext_err!("{F}: a style is required when 'blink' is set"))
}
(Some("block"), false) => SteadyBlock,
(Some("block"), true) => BlinkingBlock,
(Some("underline"), false) => SteadyUnderScore,
(Some("underline"), true) => BlinkingUnderScore,
(Some("bar"), false) => SteadyBar,
(Some("bar"), true) => BlinkingBar,
(Some(other), _) => {
return Err(ext_err!(
"{F}: style must be 'block', 'underline' or 'bar' (got '{other}')"
))
}
};
if active() {
write_stdout(seq(cmd).as_bytes());
state().cursor_styled = style.is_some();
}
Ok(())
})?,
)?;
// tui.clearLine(mode): EL. Whole-line and to-start clears also return the
// cursor to column 1 (the natural spot to redraw from); to-end does not
// move it.
tui.raw_set(
"clearLine",
lua.create_function(|_, mode: LuaValue| {
emit(match clear_mode("tui.clearLine", &mode)? {
-1 => "\x1b[1K\r",
1 => "\x1b[0K",
_ => "\x1b[2K\r",
});
Ok(())
})?,
)?;
// tui.clearScreen(mode): ED, same modes; whole-screen and to-start clears
// home the cursor.
tui.raw_set(
"clearScreen",
lua.create_function(|_, mode: LuaValue| {
emit(match clear_mode("tui.clearScreen", &mode)? {
-1 => "\x1b[1J\x1b[H",
1 => "\x1b[0J",
_ => "\x1b[2J\x1b[H",
});
Ok(())
})?,
)?;
// tui.altScreen(on): the 1049 alternate screen (saves/restores the cursor
// too). Idempotent — re-entering is ignored — and tracked, so an exit or
// error always lands back on the normal screen.
tui.raw_set(
"altScreen",
lua.create_function(|_, v: LuaValue| {
let on = match v {
LuaValue::Boolean(b) => b,
other => {
return Err(ext_err!(
"tui.altScreen: expected a boolean (got {})",
other.type_name()
))
}
};
if active() {
let mut st = state();
if on != st.alt_screen {
let s = if on {
seq(terminal::EnterAlternateScreen)
} else {
seq(terminal::LeaveAlternateScreen)
};
write_stdout(s.as_bytes());
st.alt_screen = on;
}
}
Ok(())
})?,
)?;
// tui.scrollRegion(top, bottom): DECSTBM, 1-based inclusive; no arguments
// reset it to the full screen. Setting a region moves the cursor home. A
// set region is tracked and reset on exit.
tui.raw_set(
"scrollRegion",
lua.create_function(|_, (top, bottom): (LuaValue, LuaValue)| {
const F: &str = "tui.scrollRegion";
let top = opt_coord(F, "top", &top)?;
let bottom = opt_coord(F, "bottom", &bottom)?;
match (top, bottom) {
(None, None) => {
if active() {
write_stdout(b"\x1b[r");
state().scroll_region = false;
}
}
(Some(t), Some(b)) => {
if t >= b {
return Err(ext_err!("{F}: top ({t}) must be less than bottom ({b})"));
}
if active() {
write_stdout(format!("\x1b[{t};{b}r").as_bytes());
state().scroll_region = true;
}
}
_ => {
return Err(ext_err!(
"{F}: top and bottom must both be given (or both nil to reset)"
))
}
}
Ok(())
})?,
)?;
// tui.scroll(n): scroll the scroll region up by n lines (content moves
// up); negative scrolls down, 0 does nothing.
tui.raw_set(
"scroll",
lua.create_function(|_, v: LuaValue| {
const F: &str = "tui.scroll";
let n = int_of(&v)
.ok_or_else(|| ext_err!("{F}: expected an integer (got {})", lua_value_brief(&v)))?;
match n {
1.. => emit(&seq(terminal::ScrollUp(magnitude(n)))),
..0 => emit(&seq(terminal::ScrollDown(magnitude(n)))),
0 => {}
}
Ok(())
})?,
)?;
// tui.setTitle(text): OSC 2. The first call pushes the current title on
// the terminal's title stack (XTWINOPS 22) so exit can pop it back.
tui.raw_set(
"setTitle",
lua.create_function(|_, v: LuaValue| {
const F: &str = "tui.setTitle";
let title = string_arg(F, "title", v)?;
// A control byte would terminate the OSC string early and leak
// the rest as terminal input — reject rather than sanitize.
if title.chars().any(char::is_control) {
return Err(ext_err!("{F}: title must not contain control characters"));
}
if active() {
let mut st = state();
let mut out = String::new();
if !st.title_pushed {
out.push_str("\x1b[22;2t");
st.title_pushed = true;
}
out.push_str(&format!("\x1b]2;{title}\x07"));
write_stdout(out.as_bytes());
}
Ok(())
})?,
)?;
// tui.setClipboard(text): OSC 52 write (any bytes; base64 on the wire).
// Write-only — querying the clipboard is disabled by most terminals.
tui.raw_set(
"setClipboard",
lua.create_function(|_, v: LuaValue| {
const F: &str = "tui.setClipboard";
let bytes = match v {
LuaValue::String(s) => s.as_bytes().to_vec(),
other => {
return Err(ext_err!("{F}: text must be a string (got {})", other.type_name()))
}
};
let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
emit(&format!("\x1b]52;c;{b64}\x07"));
Ok(())
})?,
)?;
// tui.reset(): DECSTR soft reset — un-sticks a garbled terminal (wrong
// charset, stuck attributes) without clearing the screen.
tui.raw_set(
"reset",
lua.create_function(|_, ()| {
if active() {
write_stdout(b"\x1b[!p");
// DECSTR itself makes the cursor visible and resets the
// scroll region; keep the tracking in sync. It does not leave
// the alternate screen or touch the title stack.
let mut st = state();
st.cursor_hidden = false;
st.scroll_region = false;
}
Ok(())
})?,
)?;
Ok(())
}
fn seq_of_visibility(show: bool) -> String {
if show {
seq(cursor::Show)
} else {
seq(cursor::Hide)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn restore_sequence_bytes() {
let all = TermState {
alt_screen: true,
cursor_hidden: true,
cursor_styled: true,
scroll_region: true,
title_pushed: true,
};
assert_eq!(restore_sequence(&all), "\x1b[r\x1b[0 q\x1b[?25h\x1b[?1049l\x1b[23;2t");
assert_eq!(restore_sequence(&CLEAN), "");
assert_eq!(
restore_sequence(&TermState { cursor_hidden: true, ..CLEAN }),
"\x1b[?25h"
);
}
#[test]
fn cleanup_clears_tracked_state_and_is_idempotent() {
*state() = TermState {
alt_screen: true,
cursor_hidden: true,
cursor_styled: true,
scroll_region: true,
title_pushed: true,
};
cleanup();
{
let st = state();
assert!(
!st.alt_screen
&& !st.cursor_hidden
&& !st.cursor_styled
&& !st.scroll_region
&& !st.title_pushed
);
}
cleanup(); // second call must be a no-op (nothing tracked)
}
#[test]
fn coord_and_mode_parsing() {
use LuaValue::{Boolean, Integer, Nil, Number};
assert_eq!(opt_coord("f", "row", &Nil).unwrap(), None);
assert_eq!(opt_coord("f", "row", &Integer(3)).unwrap(), Some(3));
assert_eq!(opt_coord("f", "row", &Number(3.0)).unwrap(), Some(3));
assert_eq!(opt_coord("f", "row", &Integer(1 << 20)).unwrap(), Some(u16::MAX));
for bad in [Integer(0), Integer(-2), Number(1.5), Boolean(true)] {
assert!(opt_coord("f", "row", &bad).is_err(), "{bad:?}");
}
let err = opt_coord("tui.moveTo", "row", &Integer(0)).unwrap_err();
assert!(err.to_string().contains("row must be a positive integer (got 0)"), "{err}");
assert_eq!(clear_mode("f", &Nil).unwrap(), 0);
for m in [-1, 0, 1] {
assert_eq!(clear_mode("f", &Integer(m)).unwrap(), m);
}
for bad in [Integer(2), Integer(-2), Number(0.5)] {
assert!(clear_mode("f", &bad).is_err(), "{bad:?}");
}
assert_eq!(opt_delta("f", "dx", &Nil).unwrap(), 0);
assert_eq!(opt_delta("f", "dx", &Integer(-7)).unwrap(), -7);
assert!(opt_delta("f", "dx", &Boolean(false)).is_err());
assert_eq!(magnitude(-7), 7);
assert_eq!(magnitude(i64::MIN), u16::MAX);
}
}

@ -1,12 +1,20 @@
use std::ffi::c_void;
use mlua::prelude::LuaResult;
use mlua::{Lua, Value as LuaValue};
use mlua::{LightUserData, Lua, Value as LuaValue};
use serde_json;
const UTILS_LUA: &str = include_str!("../../lua/stdlib/utils.lua");
// utils.NULL is a null-pointer light userdata - the same sentinel mlua itself
// uses for JSON null (mlua::Value::NULL), and the same convention flowbox uses.
// A stable address used as the identity of utils.NULL.
static NULL_MARKER: u8 = 0;
fn null_lud() -> LightUserData {
LightUserData(std::ptr::addr_of!(NULL_MARKER) as *mut c_void)
}
pub(crate) fn is_null(val: &LuaValue) -> bool {
matches!(val, LuaValue::LightUserData(p) if p.0.is_null())
matches!(val, LuaValue::LightUserData(p) if *p == null_lud())
}
// ---------------------------------------------------------------------------
@ -30,17 +38,7 @@ pub(crate) fn lua_to_json(val: LuaValue, depth: u32) -> LuaResult<serde_json::Va
.ok_or_else(|| {
mlua::Error::external("utils.toJSON: NaN and Infinity cannot be serialized as JSON")
}),
LuaValue::String(s) => {
// JSON strings must be UTF-8. Lua strings are byte strings, so give a
// clear prefixed error instead of mlua's bare conversion message.
let bytes = s.as_bytes();
match std::str::from_utf8(&bytes) {
Ok(text) => Ok(serde_json::Value::String(text.to_owned())),
Err(_) => Err(mlua::Error::external(
"utils.toJSON: string is not valid UTF-8 and cannot be encoded as JSON",
)),
}
}
LuaValue::String(s) => Ok(serde_json::Value::String(s.to_str()?.to_owned())),
LuaValue::Table(t) => table_to_json(t, depth),
other => Err(mlua::Error::external(format!(
"utils.toJSON: cannot serialize value of type '{}'",
@ -50,7 +48,7 @@ pub(crate) fn lua_to_json(val: LuaValue, depth: u32) -> LuaResult<serde_json::Va
}
pub(crate) fn table_to_json(t: mlua::Table, depth: u32) -> LuaResult<serde_json::Value> {
let seq_len = t.raw_len();
let seq_len = t.raw_len() as usize;
// Try array serialization: every key must be a sequential integer in 1..=seq_len.
if seq_len > 0 {
@ -91,14 +89,6 @@ pub(crate) fn table_to_json(t: mlua::Table, depth: u32) -> LuaResult<serde_json:
)))
}
};
// Stringifying integer keys can collide with a real string key (e.g.
// both `[1]` and `["1"]`). Silently dropping one would be data loss, so
// fail loudly like every other unrepresentable case here.
if map.contains_key(&key) {
return Err(mlua::Error::external(format!(
"utils.toJSON: duplicate object key '{key}' (an integer key collides with a string key)"
)));
}
map.insert(key, lua_to_json(v, depth + 1)?);
}
Ok(serde_json::Value::Object(map))
@ -110,7 +100,7 @@ pub(crate) fn table_to_json(t: mlua::Table, depth: u32) -> LuaResult<serde_json:
fn json_to_lua(lua: &Lua, val: serde_json::Value) -> LuaResult<LuaValue> {
match val {
serde_json::Value::Null => Ok(LuaValue::NULL),
serde_json::Value::Null => Ok(LuaValue::LightUserData(null_lud())),
serde_json::Value::Bool(b) => Ok(LuaValue::Boolean(b)),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
@ -144,18 +134,13 @@ fn json_to_lua(lua: &Lua, val: serde_json::Value) -> LuaResult<LuaValue> {
pub(super) fn install(lua: &Lua) -> LuaResult<()> {
let utils = lua.create_table()?;
utils.raw_set("NULL", LuaValue::NULL)?;
utils.raw_set("NULL", LuaValue::LightUserData(null_lud()))?;
utils.raw_set(
"isNull",
lua.create_function(|_, val: LuaValue| Ok(is_null(&val)))?,
)?;
utils.raw_set(
"dump",
lua.create_function(|_, val: LuaValue| Ok(super::lua_dump::dump(val).into_owned()))?,
)?;
utils.raw_set(
"toJSON",
lua.create_function(|_, (val, pretty): (LuaValue, Option<bool>)| {
@ -181,6 +166,6 @@ pub(super) fn install(lua: &Lua) -> LuaResult<()> {
lua.globals().raw_set("utils", utils)?;
// Lua side adds: utils.try, utils.tryn
// Lua side adds: utils.dump, utils.try, utils.tryn
lua.load(UTILS_LUA).set_name("@[stdlib/utils]").exec()
}

2
tmp/.gitignore vendored

@ -1,2 +0,0 @@
*
!.gitignore
Loading…
Cancel
Save