diff --git a/.tests/parse/axes/axes_parser.test.lua b/.tests/parse/axes/axes_parser.test.lua new file mode 100644 index 0000000..527000b --- /dev/null +++ b/.tests/parse/axes/axes_parser.test.lua @@ -0,0 +1,158 @@ +local Vector3 = require("worldeditadditions.utils.vector3") + +local facing_dirs = dofile("./.tests/parse/axes/include_facing_dirs.lua") + +local parse = require("worldeditadditions.utils.parse.axes_parser") +local parse_axes = parse.keytable + + +describe("parse_axes", function() + + -- Basic tests + it("should work on single horizontal axes", function() + local minv, maxv = parse_axes({ + "x", "3", + "-z", "10", + }, facing_dirs.x_pos) + assert.is.truthy(minv) + assert.are.same(Vector3.new(0, 0, -10), minv) + assert.are.same(Vector3.new(3, 0, 0), maxv) + end) + + it("should handle axis clumps and orphan (universal) values", function() + local minv, maxv = parse_axes({ + "xz", "-3", + "10", + }, facing_dirs.x_pos) + assert.is.truthy(minv) + assert.are.same(Vector3.new(-3, 0, -3), minv) + assert.are.same(Vector3.new(10, 10, 10), maxv) + end) + + it("should work on directions and their abriviations", function() + local minv, maxv = parse_axes({ + "l", "3", -- +z + "-r", "-3", -- +z + "b", "-10", -- -x + }, facing_dirs.x_pos) + assert.is.truthy(minv) + assert.are.same(Vector3.new(0, 0, -3), minv) + assert.are.same(Vector3.new(10, 0, 3), maxv) + end) + + it("should work with compass directions and their abriviations", function() + local minv, maxv = parse_axes({ + "n", "3", -- +z + "south", "3", -- -z + "-west", "10", -- +x + }, facing_dirs.x_pos) + assert.is.truthy(minv) + assert.are.same(Vector3.new(0, 0, -3), minv) + assert.are.same(Vector3.new(10, 0, 3), maxv) + end) + + it("should work with ?", function() + local minv, maxv = parse_axes({ + "?", "3", -- -z + }, facing_dirs.z_neg) + assert.is.truthy(minv) + assert.are.same(Vector3.new(0, 0, -3), minv) + assert.are.same(Vector3.new(0, 0, 0), maxv) + end) + + it("should work with complex relative / absolute combinations", function() + local minv, maxv = parse_axes({ + "f", "3", -- +x + "left", "10", -- +z + "y", "77", + "x", "30", + "back", "99", + }, facing_dirs.x_pos) + assert.is.truthy(minv) + assert.are.same(Vector3.new(-99, 0, 0), minv) + assert.are.same(Vector3.new(33, 77, 10), maxv) + end) + + it("should work with complex relative / absolute combinations with negative facing_dirs", function() + local minv, maxv = parse_axes({ + "f", "3", -- -z + "l", "10", -- +x + "y", "77", + "x", "30", + "b", "99", -- +z + }, facing_dirs.z_neg) + assert.is.truthy(minv) + assert.are.same(Vector3.new(0, 0, -3), minv) + assert.are.same(Vector3.new(40, 77, 99), maxv) + end) + + it("should return 2 0,0,0 vectors if no input", function() + local minv, maxv = parse_axes({ + -- No input + }, facing_dirs.z_neg) + assert.is.truthy(minv) + assert.are.same(Vector3.new(0, 0, 0), minv) + assert.are.same(Vector3.new(0, 0, 0), maxv) + end) + + -- Options tests + it("should mirror the max values of the two vectors if mirroring keyword is present", function() + local minv, maxv = parse_axes({ + "x", "3", + "f", "-5", -- +x + "z", "-10", + "mir", + }, facing_dirs.x_pos) + assert.is.truthy(minv) + assert.are.same(Vector3.new(-5, 0, -10), minv) + assert.are.same(Vector3.new(5, 0, 10), maxv) + end) + + it("should return a single vector if 'sum' input is truthy", function() + local minv, maxv = parse_axes({ + "x", "3", + "z", "-10", + }, facing_dirs.x_pos,"sum") + assert.is.truthy(minv) + assert.are.same(Vector3.new(3, 0, -10), minv) + assert.are.same(nil, maxv) + end) + + it("should dissable mirroring if 'sum' input is truthy", function() + local minv, maxv = parse_axes({ + "x", "3", + "f", "-5", -- +x + "z", "-10", + "sym", + }, facing_dirs.x_pos,"sum") + assert.is.truthy(minv) + assert.are.same(Vector3.new(-2, 0, -10), minv) + assert.are.same(nil, maxv) + end) + + -- Error tests + it("should return error if bad axis/dir", function() + local minv, maxv = parse_axes({ + "f", "3", -- +x + "lift", "10", -- Invalid axis + "y", "77", + "x", "30", + "back", "99", -- -x + }, facing_dirs.x_pos) + assert.are.same(false, minv) + assert.are.same("string", type(maxv)) + end) + + it("should return error if bad value", function() + local minv, maxv = parse_axes({ + "f", "3", -- +x + "left", "10", -- +z + "y", "!Q", -- Invalid value + "x", "30", + "back", "99", + }, facing_dirs.x_pos) + assert.are.same(false, minv) + assert.are.same("string", type(maxv)) + end) + +end) diff --git a/worldeditadditions/lib/selection/selection.lua b/worldeditadditions/lib/selection/selection.lua index a1e5ed0..c4fdc0c 100644 --- a/worldeditadditions/lib/selection/selection.lua +++ b/worldeditadditions/lib/selection/selection.lua @@ -71,7 +71,7 @@ end -- @param str string String to check (be sure to remove any + or -). -- @return bool If string is a valid dir then true. function selection.check_dir(str) - return (str == "front" or str == "back" or str == "left" or str == "right" or str == "up" or str == "down") + return (str == "facing" or str == "front" or str == "back" or str == "left" or str == "right" or str == "up" or str == "down") end return selection diff --git a/worldeditadditions/utils/parse/axes_parser.lua b/worldeditadditions/utils/parse/axes_parser.lua new file mode 100644 index 0000000..8310a83 --- /dev/null +++ b/worldeditadditions/utils/parse/axes_parser.lua @@ -0,0 +1,224 @@ +-- ██████ █████ ██████ ███████ ███████ █████ ██ ██ ███████ ███████ +-- ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ +-- ██████ ███████ ██████ ███████ █████ ███████ ███ █████ ███████ +-- ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ +-- ██ ██ ██ ██ ██ ███████ ███████ ██ ██ ██ ██ ███████ ███████ + +-- Error codes: https://kinsta.com/blog/http-status-codes/ + +--- FOR TESTING --- +local function unpack(tbl) + if table.unpack then + return table.unpack(tbl) + else return unpack(tbl) end +end +--------------- + +local Vector3 +if worldeditadditions then + local wea = worldeditadditions + Vector3 = dofile(wea.modpath.."/utils/vector3.lua") +else + Vector3 = require("worldeditadditions.utils.vector3") +end + +local key_instance +if worldeditadditions then + local wea = worldeditadditions + key_instance = dofile(wea.modpath.."/utils/parse/key_instance.lua") +else + key_instance = require("worldeditadditions.utils.parse.key_instance") +end + +--- Unified Axis Keywords banks +local keywords = { + -- Compass keywords + compass = { + n = "z", north = "z", + ["-n"] = "-z", ["-north"] = "-z", + s = "-z", south = "-z", + ["-s"] = "z", ["-south"] = "z", + e = "x", east = "x", + ["-e"] = "-x", ["-east"] = "-x", + w = "-x", west = "-x", + ["-w"] = "x", ["-west"] = "x", + }, + + -- Direction keywords + dir = { + ["?"] = "front", f = "front", + facing = "front", front = "front", + b = "back", back = "back", + behind = "back", rear = "back", + l = "left", left = "left", + r = "right", right = "right", + u = "up", up = "up", + d = "down", down = "down", + }, + + -- Mirroring keywords + mirroring = { + sym = true, symmetrical = true, + mirror = true, mir = true, + rev = true, reverse = true, + ["true"] = true + }, +} + +--- Initialize parser function container +local parse = {} + +--- Processes an axis declaration into ordered xyz format. (Supports axis clumping) +-- For example, "zzy" would become "yz" +-- @param: str: String: Axis declaration to parse +-- @returns: Table|Bool: axis | axes | false +function parse.axis(str) + local axes, ret = {"x","y","z"}, {} + for i,v in ipairs(axes) do + if str:match(v) then table.insert(ret,v) end + end + if #ret > 0 and str:match("^[xyz]+$") then + return ret + elseif str == "h" then + return {"x", "z"} + elseif str == "v" then + return {"y"} + else return false end +end + +--- Processes an number from a string. +-- @param: str: String: string to parse +-- @returns: Number|Bool: processed number | false +function parse.num(str) + str = tostring(str) -- To prevent meltdown if str isn't a string + local num = str:match("^-?%d+%.?%d*$") + if num then + return tonumber(num) + else return false end +end + +--- Checks if a string is a valid Unified Axis Keyword. (Supports axis clumping) +-- @param: str: String: Keyword instance to parse +-- @returns: Key Instance: returns keyword type, processed keyword content and signed number (or nil) +function parse.keyword(str) + if type(str) ~= "string" then + return key_instance.new("err", "Error: \""..tostring(str).."\" is not a string.", 404) + elseif keywords.compass[str] then + str = keywords.compass[str] + end + local sign = 1 + if str:sub(1,1) == "-" then + sign = -1 + str = str:sub(2) + end + + local axes = parse.axis(str) + if axes then + return key_instance.new("axis", axes, sign) + elseif keywords.dir[str] then + return key_instance.new("dir", keywords.dir[str], sign) + elseif keywords.mirroring[str] then + return key_instance.new("rev", "mirroring") + else return key_instance.new("err", "Error: \""..str.."\" is not a valid axis, direction or keyword.", 422) + end +end + +--- Creates a vector with a length of (@param: value * @param: sign) +-- on each axis in @param: axes. +-- @param: axes: Table: List of axes to set +-- @param: value: Number: length of to set axes +-- @param: sign: Number: sign multiplier for axes +-- @returns: Vector3: processed vector +function parse.vectorize(axes,value,sign) + local ret = Vector3.new() + for i,v in ipairs(axes) do + ret[v] = value * sign + end + return ret +end + +--- Converts Unified Axis Keyword table into Vector3 instances. +-- @param: tbl: Table: Keyword table to parse +-- @param: facing: Table: Output from worldeditadditions.player_dir(name) +-- @param: sum: Bool | String | nil: Return a single vector by summing the 2 output vectors together +-- @returns: Vector3, [Vector3]: returns min, max Vector3s or sum Vector3 (if @param: sum ~= nil) +-- if error: @returns: false, String: error message +function parse.keytable(tbl, facing, sum) + local min, max = Vector3.new(), Vector3.new() + local expected, tmp = 1, {axes = {}, num = 0, sign = 1, mirror = false} + function tmp:reset() self.axis, self.sign = "", 1 end + + for i,v in ipairs(tbl) do + if v:sub(1,1) == "+" then v = v:sub(2) end + tmp.num = parse.num(v) + if expected == 1 then -- Mode 1 of state machine + -- State machine expects string + if tmp.num then + -- If this is a number treat as all axes and add to appropriate vector + if tmp.num * tmp.sign >= 0 then + max = max:add(parse.vectorize({"x","y","z"}, tmp.num, tmp.sign)) + else + min = min:add(parse.vectorize({"x","y","z"}, tmp.num, tmp.sign)) + end + -- We are still looking for axes so the state machine should remain + -- in Mode 1 for the next iteration + else + -- Else parse.keyword + local key_inst = parse.keyword(v) + -- Stop if error and return message + if key_inst:is_error() then return false, key_inst.entry end + -- Check key type and process further + if key_inst.type == "axis" then + tmp.axes = key_inst.entry + tmp.sign = key_inst.sign + elseif key_inst.type == "dir" then + tmp.axes = {facing[key_inst.entry].axis} + tmp.sign = facing[key_inst.entry].sign * key_inst.sign + elseif key_inst.type == "rev" then + tmp.mirror = true + else + -- If key type is error or unknown throw error and stop + if key_inst.type == "err" then + return false, key_inst.entry + else + return false, "Error: Unknown Key Instance type \"".. + tostring(key_inst.type).."\". Contact the devs!" + end + end + expected = 2 -- Toggle state machine to expect number (Mode 2) + end + + else -- Mode 2 of state machine + -- State machine expects number + if tmp.num then + -- If this is a number process num and add to appropriate vector + if tmp.num * tmp.sign >= 0 then + max = max:add(parse.vectorize(tmp.axes, tmp.num, tmp.sign)) + else + min = min:add(parse.vectorize(tmp.axes, tmp.num, tmp.sign)) + end + expected = 1 -- Toggle state machine to expect string (Mode 1) + else + -- Else throw an error and stop everything + return false, "Error: Expected number after \""..tostring(tbl[i-1]).. + "\", got \""..tostring(v).."\"." + end + end -- End of state machine + + end -- End of main for loop + + -- Handle Mirroring + if tmp.mirror and not sum then + max = max:max(min:abs()) + min = max:multiply(-1) + end + + if sum then return min:add(max) + else return min, max end + +end + +return { + keyword = parse.keyword, + keytable = parse.keytable, +} diff --git a/worldeditadditions/utils/parse/init.lua b/worldeditadditions/utils/parse/init.lua index 2308fe8..49c8f44 100644 --- a/worldeditadditions/utils/parse/init.lua +++ b/worldeditadditions/utils/parse/init.lua @@ -4,11 +4,18 @@ -- ██ ██ ██ ██ ██ ██ ██ -- ██ ██ ██ ██ ██ ███████ ███████ +-- Unified Axes Keyword Parser +local uak_parse = dofile(worldeditadditions.modpath.."/utils/parse/axes_parser.lua") +-- Old axis parsing functions local axes = dofile(worldeditadditions.modpath.."/utils/parse/axes.lua") worldeditadditions.parse = { + direction_keyword = uak_parse.keyword, + directions = uak_parse.keytable, + -- Old parse functions (marked for deprecation). + -- Use parse.keytable or parse.keyword instead axes = axes.parse_axes, - axis_name = axes.parse_axis_name + axis_name = axes.parse_axis_name, } dofile(worldeditadditions.modpath.."/utils/parse/chance.lua") diff --git a/worldeditadditions/utils/parse/key_instance.lua b/worldeditadditions/utils/parse/key_instance.lua new file mode 100644 index 0000000..1cec51d --- /dev/null +++ b/worldeditadditions/utils/parse/key_instance.lua @@ -0,0 +1,90 @@ +--- A container for transmitting (axis table, sign) or (dir, sign) pairs +-- and other data within parsing functions. +-- @class +local key_instance = {} +key_instance.__index = key_instance +key_instance.__name = "Key Instance" + +-- Allowed values for "type" field +local types = { + err = true, rev = true, + axis = true, dir = true, + replace = { + error = "err", + axes = "axis", + }, +} + +-- Simple function for putting stuff in quotes +local function enquote(take) + if type(take) == "string" then + return '"'..take..'"' + else return tostring(take) end +end + +--- Creates a new Key Instance. +-- This is a table with a "type" string, an entry string or table +-- and an optional signed integer (or code number in the case of errors) +-- @param: type: String: Key type (axis, dir(ection), rev (mirroring) or error). +-- @param: entry: String: The main content of the key. +-- @param: sign: Int: The signed multiplier of the key (if any). +-- @return: Key Instance: The new Key Instance. +function key_instance.new(type,entry,sign) + if types.replace[type] then + type = types.replace[type] + elseif not types[type] then + return key_instance.new("err", + "Key Instance internal error: Invalid type "..enquote(type)..".", + 500) + end + local tbl = {type = type, entry = entry} + if sign and sign ~= 0 then + if type == "err" then tbl.code = sign + else tbl.sign = sign end + end + return setmetatable(tbl, key_instance) +end + +--- Checks if Key Instance "entry" field is a table. +-- @param: tbl: Key Instance: The Key Instance to check. +-- @return: Bool: Returns true if Key Instance has a non 0 sign value. +function key_instance.entry_table(a) + if type(a.entry) == "table" then + return true + else return false end +end + +--- Checks if Key Instance has a signed multiplier. +-- @param: tbl: Key Instance: The Key Instance to check. +-- @return: Bool: Returns true if Key Instance has a non 0 sign value. +function key_instance.has_sign(a) + if not a.sign or a.sign == 0 then + return false + else return true end +end + +--- Checks if Key Instance is an error. +-- @param: tbl: Key Instance: The Key Instance to check. +-- @return: Bool: Returns true if Key Instance is an error. +function key_instance.is_error(a) + if a.type == "err" then return true + else return false end +end + +function key_instance.__tostring(a) + local ret = "{type = "..enquote(a.type)..", entry = " + if type(a.entry) == "table" and #a.entry <= 3 then + ret = ret.."{" + for _i,v in ipairs(a.entry) do + ret = ret..enquote(v)..", " + end + ret = ret:sub(1,-3).."}" + else ret = ret..enquote(a.entry) end + + if a:is_error() and a.code then ret = ret..", code = "..a.code.."}" + elseif a:has_sign() then ret = ret..", sign = "..a.sign.."}" + else ret = ret.."}" end + return ret +end + +return key_instance