diff --git a/README.md b/README.md index 71b272a..521a648 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ luarocks install mods | Module | Description | | -------------- | -------------------------------------------------------------- | | [`is`] | Type predicates for Lua values and filesystem path kinds. | +| [`keyword`] | Lua keyword helpers for reserved-word checks. | | [`List`] | Python-style list helpers for mapping, filtering, and slicing. | | [`operator`] | Operator helpers as functions. | | [`Set`] | Set operations and helpers for unique values. | @@ -72,6 +73,7 @@ Thanks to these Lua ecosystem projects: - [busted](https://github.com/lunarmodules/busted) for test framework support. [`is`]: https://luamod.github.io/mods/modules/is +[`keyword`]: https://luamod.github.io/mods/modules/keyword [`List`]: https://luamod.github.io/mods/modules/list [`operator`]: https://luamod.github.io/mods/modules/operator [`Set`]: https://luamod.github.io/mods/modules/set diff --git a/docs/src/modules/index.md b/docs/src/modules/index.md index e11e311..979510f 100644 --- a/docs/src/modules/index.md +++ b/docs/src/modules/index.md @@ -8,6 +8,7 @@ description: Overview of available Mods modules and their purpose. | Module | Description | | ----------------------------------- | -------------------------------------------------------------- | | [`is`](/modules/is) | Type predicates for Lua values and filesystem path kinds. | +| [`keyword`](/modules/keyword) | Lua keyword helpers for reserved-word checks. | | [`List`](/modules/list) | Python-style list helpers for mapping, filtering, and slicing. | | [`operator`](/modules/operator) | Operator helpers as functions. | | [`Set`](/modules/set) | Set operations and helpers for unique values. | diff --git a/docs/src/modules/keyword.md b/docs/src/modules/keyword.md new file mode 100644 index 0000000..bbf11e0 --- /dev/null +++ b/docs/src/modules/keyword.md @@ -0,0 +1,92 @@ +--- +description: Lua keyword helpers for reserved-word checks. +--- + +# `keyword` + +Lua keyword helpers. + +## Import + +```lua +local kw = require("mods.keyword") +``` + +## Dependencies + +- [`mods.List`] is used by `kwlist()`. +- [`mods.Set`] is used by `kwset()`. + +> [!NOTE] +> +> These dependencies are lazy-loaded internally 💤, so requiring `mods.keyword` +> does not immediately load them. + +## Quick Reference + +| Function | Description | +| ------------------------------------------------------ | -------------------------------------------------- | +| [`iskeyword(s)`](#fn-iskeywords) | Return `true` when `s` is a reserved Lua keyword. | +| [`isidentifier(s)`](#fn-isidentifiers) | Return `true` for valid non-keyword identifiers. | +| [`kwlist()`](#fn-kwlist) | Return Lua keywords as a [`mods.List`] of strings. | +| [`kwset()`](#fn-kwset) | Return Lua keywords as a [`mods.Set`] of strings. | +| [`normalize_identifier(s)`](#fn-normalize_identifiers) | Normalize input to a safe identifier. | + +## Functions + +### `iskeyword(s)` {#fn-iskeywords} + +Return `true` when `s` is a reserved Lua keyword. + +> [!NOTE] +> +> `goto` is treated as a keyword on Lua 5.2+ and not on Lua 5.1/LuaJIT. + +```lua +print(kw.iskeyword("function")) --> true +print(kw.iskeyword("hello")) --> false +``` + +### `kwlist()` {#fn-kwlist} + +Return Lua keywords as a [`mods.List`] of strings. + +```lua +local keywords = kw.kwlist() +print(keywords[1]) --> "and" +print(keywords[#keywords]) --> "while" +``` + +### `kwset()` {#fn-kwset} + +Return Lua keywords as a [`mods.Set`] of strings. + +```lua +local words = kw.kwset() +print(words["and"]) --> true +print(words["hello"]) --> nil +``` + +### `isidentifier(s)` {#fn-isidentifiers} + +Return `true` when `s` is a valid non-keyword Lua identifier. + +```lua +print(kw.isidentifier("hello_world")) --> true +print(kw.isidentifier("local")) --> false +``` + +### `normalize_identifier(s)` {#fn-normalize_identifiers} + +Normalize input to a safe Lua identifier. + +```lua +print(kw.normalize_identifier(" 2 bad-name ")) --> "_2_bad_name" +print(kw.normalize_identifier("local")) --> "local_" +print(kw.normalize_identifier("end")) --> "end_" +print(kw.normalize_identifier(" ")) --> "_" +print(kw.normalize_identifier(false)) --> "false_" +``` + +[`mods.List`]: /modules/list +[`mods.Set`]: /modules/set diff --git a/docs/src/modules/utils.md b/docs/src/modules/utils.md index 177ccd2..c87fe07 100644 --- a/docs/src/modules/utils.md +++ b/docs/src/modules/utils.md @@ -16,26 +16,10 @@ local utils = require("mods.utils") | Function | Description | | -------------------------------------- | ----------------------------------------------------- | -| [`isidentifier(s)`](#fn-isidentifiers) | Checks if a string is a valid non-keyword identifier. | | [`quote(v)`](#fn-quotev) | Smart-quotes a string for readable Lua-like output. | ## Functions -### `isidentifier(s)` {#fn-isidentifiers} - -Returns `true` when `s` is a valid Lua identifier and not a reserved keyword. - -```lua -print(utils.isidentifier("hello_world")) --- true - -print(utils.isidentifier("local")) --- false - -print(utils.isidentifier("2bad")) --- false -``` - ### `quote(v)` {#fn-quotev} Smart-quotes a string for readable Lua-like output. diff --git a/mods.rockspec.template b/mods.rockspec.template index 14fa121..50aba6a 100644 --- a/mods.rockspec.template +++ b/mods.rockspec.template @@ -13,7 +13,7 @@ description = { license = "MIT", summary = "Pure Lua modules", detailed = [[ -Mods provides small, focused Lua modules: List, Set, is, operator, str, stringcase, tbl, template, and validate. +Mods provides small, focused Lua modules: List, Set, is, keyword, operator, str, stringcase, tbl, template, and validate. ]], } @@ -26,6 +26,7 @@ build = { modules = { ["mods"] = "src/mods/init.lua", ["mods.is"] = "src/mods/is.lua", + ["mods.keyword"] = "src/mods/keyword.lua", ["mods.List"] = "src/mods/List.lua", ["mods.operator"] = "src/mods/operator.lua", ["mods.Set"] = "src/mods/Set.lua", diff --git a/spec/keyword_spec.lua b/spec/keyword_spec.lua new file mode 100644 index 0000000..cae22d5 --- /dev/null +++ b/spec/keyword_spec.lua @@ -0,0 +1,149 @@ +local List = require("mods.List") +local Set = require("mods.Set") +local kw = require("mods.keyword") + +local fmt = string.format +local is_lua51 = _VERSION == "Lua 5.1" + +describe("mods.keyword", function() + local fn = function() end + local co = coroutine.create(fn) + -- stylua: ignore + local kwlist = List({ + "and" , "break" , "do" , "else" , "elseif", + "end" , "false" , "for" , "function", "if" , + "in" , "local" , "nil" , "not" , "or" , + "repeat", "return", "then", "true" , "until" , "while" + }) + + if not is_lua51 then + kwlist:append("goto"):sort() + end + + local kwset = kwlist:setify() + local tests + + ------------------- + --- iskeyword() --- + ------------------- + + local non_keywords = { + "_", + "", + "Function", + "goto1", + "hello", + "local_var", + "nil?", + "while_", + {}, + 123, + false, + } + + for i = 1, #kwlist do + local input = kwlist[i] + it(fmt("iskeyword(%s) returns true", inspect(input)), function() + assert.is_true(kw.iskeyword(input)) + end) + end + + for i = 1, #non_keywords do + local input = non_keywords[i] + it(fmt("iskeyword(%s) returns false", inspect(input)), function() + assert.is_false(kw.iskeyword(input)) + end) + end + + ---------------------- + --- isidentifier() --- + ---------------------- + + -- stylua: ignore + tests = { + { "hello" , true }, + { "hello_world" , true }, + { "_name2" , true }, + { "goto" , is_lua51 }, + { "(var" , false }, + { "[var" , false }, + { "local" , false }, + { "function" , false }, + { "2bad" , false }, + { "bad-name" , false }, + { false , false }, + } + + for i = 1, #tests do + local input, expected = unpack(tests[i], 1, 2) + it(fmt("isidentifier(%s)", inspect(input)), function() + assert.are_equal(expected, kw.isidentifier(input)) + end) + end + + ------------------------------ + --- normalize_identifier() --- + ------------------------------ + + -- stylua: ignore + tests = { + ------input------|----expected---- + { " 2 bad-name " , "_2_bad_name" }, + { "local" , "local_" }, + { "" , "_" }, + { " " , "_" }, + { false , "false_" }, + { fn , "function_" }, + { {} , "table_" }, + { co , "thread_" }, + } + + for i = 1, #tests do + local input, expected = unpack(tests[i], 1, 2) + it(fmt("normalize_identifier(%s)", inspect(input)), function() + assert.are_equal(expected, kw.normalize_identifier(input)) + end) + end + + ---------------- + --- kwlist() --- + ---------------- + + describe("kwlist()", function() + it("returns all keywords in order", function() + assert.are_same(kwlist, kw.kwlist()) + end) + + it("returns a mods.List instance", function() + local kw = kw.kwlist() + assert.are_equal(List, getmetatable(kw)) + end) + + it("returns a fresh copy on each call", function() + local l1 = kw.kwlist() + local l2 = kw.kwlist() + assert.are_not_equal(l1, l2) + end) + end) + + --------------- + --- kwset() --- + --------------- + + describe("kwset()", function() + it("returns all keywords", function() + assert.are_same(kwset, kw.kwset()) + end) + + it("returns a mods.Set instance", function() + local kw = kw.kwset() + assert.are_equal(Set, getmetatable(kw)) + end) + + it("returns a fresh copy on each call", function() + local s1 = kw.kwset() + local s2 = kw.kwset() + assert.are_not_equal(s1, s2) + end) + end) +end) diff --git a/spec/utils_spec.lua b/spec/utils_spec.lua index 14b4731..205ad5c 100644 --- a/spec/utils_spec.lua +++ b/spec/utils_spec.lua @@ -5,31 +5,6 @@ local utils = require("mods.utils") describe("mods.utils", function() local tests - -------------------- - --- isidentifier --- - -------------------- - - -- stylua: ignore - tests = { - -----input---|-expected--- - { "_" , true }, - { "var" , true }, - { "var_2" , true }, - { "2var" , false }, - { "var)" , false }, - { "[var" , false }, - { "local" , false }, - { "function" , false }, - { "nil" , false }, - } - - for i = 1, #tests do - local input, expected = unpack(tests[i], 1, 2) - it(("isidentifier(%q)"):format(input), function() - assert.are_equal(expected, utils.isidentifier(input)) - end) - end - ------------- --- quote --- ------------- diff --git a/src/mods/init.lua b/src/mods/init.lua index a7911a0..0917ab3 100644 --- a/src/mods/init.lua +++ b/src/mods/init.lua @@ -1,6 +1,7 @@ local mods = {} -("is List operator Set str stringcase tbl template utils validate"):gsub("%S+", function(name) +([[is keyword List operator Set str stringcase + tbl template utils validate]]):gsub("%S+", function(name) mods[name] = "mods." .. name end) diff --git a/src/mods/keyword.lua b/src/mods/keyword.lua new file mode 100644 index 0000000..c93a27b --- /dev/null +++ b/src/mods/keyword.lua @@ -0,0 +1,84 @@ +local gsub = string.gsub +local match = string.match + +---@type mods.keyword +local M = {} + +-- Lazy-load to keep requiring mods.keyword lightweight. +local function List(ls) + List = require("mods.List") ---@diagnostic disable-line: cast-local-type + return List(ls) +end + +-- Lazy-load to keep requiring mods.keyword lightweight. +local function Set(ls) + Set = require("mods.Set") ---@diagnostic disable-line: cast-local-type + return Set(ls) +end + +-- stylua: ignore +local kwlist = { + "and" , "break" , "do" , "else" , "elseif", + "end" , "false" , "for" , "function", "if" , + "in" , "local" , "nil" , "not" , "or" , + "repeat", "return", "then", "true" , "until" , "while", +} + +if _VERSION ~= "Lua 5.1" then + table.insert(kwlist, "goto") + table.sort(kwlist) +end + +-- Use a plain lookup table for hot-path membership checks; avoid Set overhead. +local kwset = {} +for i = 1, #kwlist do + kwset[kwlist[i]] = true +end + +local function iskeyword(s) + return kwset[s] == true +end + +M.iskeyword = iskeyword + +function M.isidentifier(s) + return type(s) == "string" and not iskeyword(s) and match(s, "^[%a_][%w_]*$") ~= nil +end + +function M.kwlist() + return List(kwlist):copy() +end + +function M.kwset() + return Set(kwlist) +end + +function M.normalize_identifier(v) + if v == "" then + return "_" + end + + local vt = type(v) + local out = v + if vt == "boolean" then + out = tostring(v) + elseif vt ~= "string" then + out = vt .. "_" + end + + out = gsub(out, "^%s+", "") -- Trim leading whitespace. + out = gsub(out, "%s+$", "") -- Trim trailing whitespace. + out = gsub(out, "[^%w_]", "_") -- Replace non-identifier characters with underscores. + + if out == "" then + return "_" + elseif match(out, "^%d") then + out = "_" .. out + elseif iskeyword(out) then + out = out .. "_" + end + + return out +end + +return M diff --git a/src/mods/str.lua b/src/mods/str.lua index a92e4b8..2f8eca7 100644 --- a/src/mods/str.lua +++ b/src/mods/str.lua @@ -1,6 +1,6 @@ local List = require("mods.List") local stringcase = require("mods.stringcase") -local utils = require("mods.utils") +local isidentifier = require("mods.keyword").isidentifier local byte = string.byte local char = string.char @@ -349,7 +349,7 @@ M.isupper = is_jit and isupper_bytes or isupper_pattern M.isdigit = M.isdecimal M.isnumeric = M.isdecimal -M.isidentifier = utils.isidentifier +M.isidentifier = isidentifier function M.isprintable(s) local len = #s diff --git a/src/mods/utils.lua b/src/mods/utils.lua index bcc4637..9b8ae11 100644 --- a/src/mods/utils.lua +++ b/src/mods/utils.lua @@ -1,20 +1,9 @@ local find = string.find local gsub = string.gsub -local match = string.match ---@type mods.utils local M = {} -local lua_keywords = {} -([[ and break do else elseif end false for function goto if in local - nil not or repeat return then true until while ]]):gsub("%w+", function(w) - lua_keywords[w] = true -end) - -function M.isidentifier(s) - return not lua_keywords[s] and match(s, "^[%a_][%w_]*$") ~= nil -end - function M.quote(v) if find(v, '"', 1, true) and not find(v, "'", 1, true) then return "'" .. v .. "'" diff --git a/types/keyword.lua b/types/keyword.lua new file mode 100644 index 0000000..cc674f2 --- /dev/null +++ b/types/keyword.lua @@ -0,0 +1,59 @@ +---@meta mods.keyword + +---Lua keyword helpers. +---@class mods.keyword +local M = {} + +---Return `true` when `s` is a reserved Lua keyword. +--- +---```lua +---print(kw.iskeyword("function")) --> true +---print(kw.iskeyword("hello")) --> false +---``` +---@param v any +---@return boolean ok +---@nodiscard +function M.iskeyword(v) end + +---Return `true` when `s` is a valid non-keyword Lua identifier. +--- +---```lua +---print(kw.isidentifier("hello_world")) --> true +---print(kw.isidentifier("local")) --> false +---``` +---@param v any +---@return boolean ok +---@nodiscard +function M.isidentifier(v) end + +---Return Lua keywords as a `mods.List` of strings. +--- +---```lua +---local words = keyword.kwlist() +---print(words[1]) --> "and" +---``` +---@return mods.List words +---@nodiscard +function M.kwlist() end + +---Return Lua keywords as a `mods.Set` of strings. +--- +---```lua +---local words = kw.kwset() +---print(words["and"]) --> true +---``` +---@return mods.Set words +---@nodiscard +function M.kwset() end + +---Normalize an input into a safe Lua identifier. +--- +---```lua +---print(kw.normalize_identifier(" 2 bad-name ")) --> "_2_bad_name" +---``` +---@param s any +---@return string ident +---@nodiscard +function M.normalize_identifier(s) end + +return M diff --git a/types/mods.lua b/types/mods.lua index 2b22c96..30dfce8 100644 --- a/types/mods.lua +++ b/types/mods.lua @@ -2,6 +2,7 @@ ---@class mods ---@field is mods.is +---@field keyword mods.keyword ---@field List mods.List ---@field operator mods.operator ---@field Set mods.Set diff --git a/types/utils.lua b/types/utils.lua index f5481c7..b5d7fa0 100644 --- a/types/utils.lua +++ b/types/utils.lua @@ -4,17 +4,6 @@ ---@class mods.utils local M = {} ----Return true if `s` is a valid non-keyword Lua identifier. ---- ----```lua ----print(utils.isidentifier("name")) -- true ----print(utils.isidentifier("local")) -- false ----``` ----@param s string ----@return boolean ok ----@nodiscard -function M.isidentifier(s) end - ---Smart-quote a string for readable Lua-like output. --- ---```lua