diff --git a/scripts/lua/modules/liluat.lua b/scripts/lua/modules/liluat.lua new file mode 100644 index 0000000000..0e9e194684 --- /dev/null +++ b/scripts/lua/modules/liluat.lua @@ -0,0 +1,532 @@ +--[[ +-- liluat - Lightweight Lua Template engine +-- +-- Project page: https://github.com/FSMaxB/liluat +-- +-- liluat is based on slt2 by henix, see https://github.com/henix/slt2 +-- +-- Copyright © 2016 Max Bruckner +-- Copyright © 2011-2016 henix +-- +-- Permission is hereby granted, free of charge, to any person obtaining a copy +-- of this software and associated documentation files (the "Software"), to deal +-- in the Software without restriction, including without limitation the rights +-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +-- copies of the Software, and to permit persons to whom the Software is furnished +-- to do so, subject to the following conditions: +-- +-- The above copyright notice and this permission notice shall be included in +-- all copies or substantial portions of the Software. +-- +-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +-- WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +-- IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +--]] + +local liluat = { + private = {} --used to expose private functions for testing +} + +-- print the current version +liluat.version = function () + return "1.2.0" +end + +-- returns a string containing the fist line until the last line +local function string_lines(lines, first, last) + -- allow negative line numbers + first = (first >= 1) and first or 1 + + local start_position + local current_position = 1 + local line_counter = 1 + repeat + if line_counter == first then + start_position = current_position + end + current_position = lines:find('\n', current_position + 1, true) + line_counter = line_counter + 1 + until (line_counter == (last + 1)) or (not current_position) + + return lines:sub(start_position, current_position) +end +liluat.private.string_lines = string_lines + +-- escape a string for use in lua patterns +-- (this simply prepends all non alphanumeric characters with '%' +local function escape_pattern(text) + return text:gsub("([^%w])", "%%%1" --[[function (match) return "%"..match end--]]) +end +liluat.private.escape_pattern = escape_pattern + +-- recursively copy a table +local function clone_table(table) + local clone = {} + + for key, value in pairs(table) do + if type(value) == "table" then + clone[key] = clone_table(value) + else + clone[key] = value + end + end + + return clone +end +liluat.private.clone_table = clone_table + +-- recursively merge two tables, the second one has precedence +-- if 'shallow' is set, the second table isn't copied recursively, +-- its content is only referenced instead +local function merge_tables(a, b, shallow) + a = a or {} + b = b or {} + + local merged = clone_table(a) + + for key, value in pairs(b) do + if (type(value) == "table") and (not shallow) then + if a[key] then + merged[key] = merge_tables(a[key], value) + else + merged[key] = clone_table(value) + end + else + merged[key] = value + end + end + + return merged +end +liluat.private.merge_tables = merge_tables + +local default_options = { + start_tag = "{{", + end_tag = "}}", + trim_right = "code", + trim_left = "code" +} + +-- initialise table of options (use the provided, default otherwise) +local function initialise_options(options) + return merge_tables(default_options, options) +end + +-- creates an iterator that iterates over all chunks in the given template +-- a chunk is either a template delimited by start_tag and end_tag or a normal text +-- the iterator also returns the type of the chunk as second return value +local function all_chunks(template, options) + options = initialise_options(options) + + -- pattern to match a template chunk + local template_pattern = escape_pattern(options.start_tag) .. "([+-]?)(.-)([+-]?)" .. escape_pattern(options.end_tag) + local include_pattern = "^"..escape_pattern(options.start_tag) .. "[+-]?include:(.-)[+-]?" .. escape_pattern(options.end_tag) + local expression_pattern = "^"..escape_pattern(options.start_tag) .. "[+-]?=(.-)[+-]?" .. escape_pattern(options.end_tag) + local position = 1 + + return function () + if not position then + return nil + end + + local template_start, template_end, trim_left, template_capture, trim_right = template:find(template_pattern, position) + + local chunk = {} + if template_start == position then -- next chunk is a template chunk + if trim_left == "+" then + chunk.trim_left = false + elseif trim_left == "-" then + chunk.trim_left = true + end + if trim_right == "+" then + chunk.trim_right = false + elseif trim_right == "-" then + chunk.trim_right = true + end + + local include_start, include_end, include_capture = template:find(include_pattern, position) + local expression_start, expression_end, expression_capture + if not include_start then + expression_start, expression_end, expression_capture = template:find(expression_pattern, position) + end + + if include_start then + chunk.type = "include" + chunk.text = include_capture + elseif expression_start then + chunk.type = "expression" + chunk.text = expression_capture + else + chunk.type = "code" + chunk.text = template_capture + end + + position = template_end + 1 + return chunk + elseif template_start then -- next chunk is a text chunk + chunk.type = "text" + chunk.text = template:sub(position, template_start - 1) + position = template_start + return chunk + else -- no template chunk found --> either text chunk until end of file or no chunk at all + chunk.text = template:sub(position) + chunk.type = "text" + position = nil + return (#chunk.text > 0) and chunk or nil + end + end +end +liluat.private.all_chunks = all_chunks + +local function read_entire_file(path) + assert(path) + local file = assert(io.open(path)) + local file_content = file:read('*a') + file:close() + return file_content +end +liluat.private.read_entire_file = read_entire_file + +-- a whitelist of allowed functions +local sandbox_whitelist = { + ipairs = ipairs, + next = next, + pairs = pairs, + rawequal = rawequal, + rawget = rawget, + rawset = rawset, + select = select, + tonumber = tonumber, + tostring = tostring, + type = type, + unpack = unpack, + string = string, + table = table, + math = math, + os = { + date = os.date, + difftime = os.difftime, + time = os.time, + }, + coroutine = coroutine +} + +-- puts line numbers in front of a string and optionally highlights a single line +local function prepend_line_numbers(lines, first, highlight) + first = (first and (first >= 1)) and first or 1 + lines = lines:gsub("\n$", "") -- make sure the last line isn't empty + lines = lines:gsub("^\n", "") -- make sure the first line isn't empty + + local current_line = first + 1 + return string.format("%3d: ", first) .. lines:gsub('\n', function () + local highlight_char = ' ' + if current_line == tonumber(highlight) then + highlight_char = '> ' + end + + local replacement = string.format("\n%3d:%s", current_line, highlight_char) + current_line = current_line + 1 + + return replacement + end) +end +liluat.private.prepend_line_numbers = prepend_line_numbers + +-- creates a function in a sandbox from a given code, +-- name of the execution context and an environment +-- that will be available inside the sandbox, +-- optionally overwrite the whitelist +local function sandbox(code, name, environment, whitelist, reference) + whitelist = whitelist or sandbox_whitelist + name = name or 'unknown' + + -- prepare the environment + environment = merge_tables(whitelist, environment, reference) + + local func + local error_message + if setfenv then --Lua 5.1 and compatible + if code:byte(1) == 27 then + error("Lua bytecode not permitted.", 2) + end + func, error_message = loadstring(code) + if func then + setfenv(func, environment) + end + else -- Lua 5.2 and later + func, error_message = load(code, name, 't', environment) + end + + -- handle compile error and print pretty error message + if not func then + local line_number, message = error_message:match(":(%d+):(.*)") + -- lines before and after the error + local lines = string_lines(code, line_number - 3, line_number + 3) + error( + 'Syntax error in sandboxed code "' .. name .. '" in line ' .. line_number .. ':\n' + .. message .. '\n\n' + .. prepend_line_numbers(lines, line_number - 3, line_number), + 3 + ) + end + + return func +end +liluat.private.sandbox = sandbox + +local function parse_string_literal(string_literal) + return sandbox('return' .. string_literal, nil, nil, {})() +end +liluat.private.parse_string_literal = parse_string_literal + +-- add an include to the include_list and throw an error if +-- an inclusion cycle is detected +local function add_include_and_detect_cycles(include_list, path) + local parent = include_list[0] + while parent do -- while the root hasn't been reached + if parent[path] then + error("Cyclic inclusion detected") + end + + parent = parent[0] + end + + include_list[path] = { + [0] = include_list + } +end +liluat.private.add_include_and_detect_cycles = add_include_and_detect_cycles + +-- extract the name of a directory from a path +local function dirname(path) + return path:match("^(.*/).-$") or "" +end +liluat.private.dirname = dirname + +-- splits a template into chunks +-- chunks are either a template delimited by start_tag and end_tag +-- or a text chunk (everything else) +-- @return table +local function parse(template, options, output, include_list, current_path) + options = initialise_options(options) + current_path = current_path or "." -- current include path + + include_list = include_list or {} -- a list of files that were included + local output = output or {} + + for chunk in all_chunks(template, options) do + -- handle includes + if chunk.type == "include" then -- include chunk + local include_path_literal = chunk.text + local path = parse_string_literal(include_path_literal) + + -- build complete path + if path:find("^/") then + --absolute path, don't modify + elseif options.base_path then + path = options.base_path .. "/" .. path + else + path = dirname(current_path) .. path + end + + add_include_and_detect_cycles(include_list, path) + + local included_template = read_entire_file(path) + parse(included_template, options, output, include_list[path], path) + elseif (chunk.type == "text") and output[#output] and (output[#output].type == "text") then + -- ensure that no two text chunks follow each other + output[#output].text = output[#output].text .. chunk.text + else -- other chunk + table.insert(output, chunk) + end + + end + + return output +end +liluat.private.parse = parse + +-- inline included template files +-- @return string +function liluat.inline(template, options, start_path) + options = initialise_options(options) + + local output = {} + for _,chunk in ipairs(parse(template, options, nil, nil, start_path)) do + if chunk.type == "expression" then + table.insert(output, options.start_tag .. "=" .. chunk.text .. options.end_tag) + elseif chunk.type == "code" then + table.insert(output, options.start_tag .. chunk.text .. options.end_tag) + else + table.insert(output, chunk.text) + end + end + + return table.concat(output) +end + +-- @return { string } +function liluat.get_dependencies(template, options, start_path) + options = initialise_options(options) + + local include_list = {} + parse(template, options, nil, include_list, start_path) + + local dependencies = {} + local have_seen = {} -- list of includes that were already added + local function recursive_traversal(list) + for key, value in pairs(list) do + if (type(key) == "string") and (not have_seen[key]) then + have_seen[key] = true + table.insert(dependencies, key) + recursive_traversal(value) + end + end + end + + recursive_traversal(include_list) + return dependencies +end + +-- compile a template into lua code +-- @return { name = string, code = string / function} +function liluat.compile(template, options, template_name, start_path) + options = initialise_options(options) + template_name = template_name or 'liluat.compile' + + local output_function = "__liluat_output_function" + + -- split the template string into chunks + local lexed_template = parse(template, options, nil, nil, start_path) + + -- table of code fragments the template is compiled into + local lua_code = {} + + for i, chunk in ipairs(lexed_template) do + -- check if the chunk is a template (either code or expression) + if chunk.type == "expression" then + table.insert(lua_code, output_function..'('..chunk.text..')') + elseif chunk.type == "code" then + table.insert(lua_code, chunk.text) + else --text chunk + -- determine if this block needs to be trimmed right + -- (strip newline) + local trim_right = false + if lexed_template[i - 1] and (lexed_template[i - 1].trim_right == true) then + trim_right = true + elseif lexed_template[i - 1] and (lexed_template[i - 1].trim_right == false) then + trim_right = false + elseif options.trim_right == "all" then + trim_right = true + elseif options.trim_right == "code" then + trim_right = lexed_template[i - 1] and (lexed_template[i - 1].type == "code") + elseif options.trim_right == "expression" then + trim_right = lexed_template[i - 1] and (lexed_template[i - 1].type == "expression") + end + + -- determine if this block needs to be trimmed left + -- (strip whitespaces in front) + local trim_left = false + if lexed_template[i + 1] and (lexed_template[i + 1].trim_left == true) then + trim_left = true + elseif lexed_template[i + 1] and (lexed_template[i + 1].trim_left == false) then + trim_left = false + elseif options.trim_left == "all" then + trim_left = true + elseif options.trim_left == "code" then + trim_left = lexed_template[i + 1] and (lexed_template[i + 1].type == "code") + elseif options.trim_left == "expression" then + trim_left = lexed_template[i + 1] and (lexed_template[i + 1].type == "expression") + end + + if trim_right and trim_left then + -- both at once + if i == 1 then + if chunk.text:find("^.*\n") then + chunk.text = chunk.text:match("^(.*\n)%s-$") + elseif chunk.text:find("^%s-$") then + chunk.text = "" + end + elseif chunk.text:find("^\n") then --have to trim a newline + if chunk.text:find("^\n.*\n") then --at least two newlines + chunk.text = chunk.text:match("^\n(.*\n)%s-$") or chunk.text:match("^\n(.*)$") + elseif chunk.text:find("^\n%s-$") then + chunk.text = "" + else + chunk.text = chunk.text:gsub("^\n", "") + end + else + chunk.text = chunk.text:match("^(.*\n)%s-$") or chunk.text + end + elseif trim_left then + if i == 1 and chunk.text:find("^%s-$") then + chunk.text = "" + else + chunk.text = chunk.text:match("^(.*\n)%s-$") or chunk.text + end + elseif trim_right then + chunk.text = chunk.text:gsub("^\n", "") + end + if not (chunk.text == "") then + table.insert(lua_code, output_function..'('..string.format("%q", chunk.text)..')') + end + end + end + + return { + name = template_name, + code = table.concat(lua_code, '\n') + } +end + +-- compile a file +-- @return { name = string, code = string / function } +function liluat.compile_file(filename, options) + return liluat.compile(read_entire_file(filename), options, filename, filename) +end + +-- @return a coroutine function +function liluat.render_coroutine(template, environment, options) + options = initialise_options(options) + environment = merge_tables({__liluat_output_function = coroutine.yield}, environment, options.reference) + + return sandbox(template.code, template.name, environment, nil, options.reference) +end + +-- @return string +function liluat.render(t, env, options) + options = initialise_options(options) + + local result = {} + + -- add closure that renders the text into the result table + env = merge_tables({ + __liluat_output_function = function (text) + table.insert(result, text) end + }, + env, + options.reference + ) + + -- compile and run the lua code + local render_function = sandbox(t.code, t.name, env, nil, options.reference) + local status, error_message = pcall(render_function) + if not status then + local line_number, message = error_message:match(":(%d+):(.*)") + -- lines before and after the error + local lines = string_lines(t.code, line_number - 3, line_number + 3) + error( + 'Runtime error in sandboxed code "' .. t.name .. '" in line ' .. line_number .. ':\n' + .. message .. '\n\n' + .. prepend_line_numbers(lines, line_number - 3, line_number), + 2 + ) + end + + return table.concat(result) +end + +return liluat