1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810 |
- -- Copyright 2015-2020 Mitchell. See LICENSE.
- -- Sponsored by the Library of the University of Antwerp.
- -- Contributions from Ana Balan.
- -- Lupa templating engine.
-
- --[[ This comment is for LuaDoc.
- ---
- -- Lupa is a Jinja2 template engine implementation written in Lua and supports
- -- Lua syntax within tags and variables.
- module('lupa')]]
- local M = {}
-
- local lpeg = require('lpeg')
- lpeg.locale(lpeg)
- local space, newline = lpeg.space, lpeg.P('\r')^-1 * '\n'
- local P, S, V = lpeg.P, lpeg.S, lpeg.V
- local C, Cc, Cg, Cp, Ct = lpeg.C, lpeg.Cc, lpeg.Cg, lpeg.Cp, lpeg.Ct
-
- ---
- -- Lupa's expression filters.
- -- @class table
- -- @name filters
- M.filters = {}
-
- ---
- -- Lupa's value tests.
- -- @class table
- -- @name tests
- M.tests = {}
-
- ---
- -- Lupa's template loaders.
- -- @class table
- -- @name loaders
- M.loaders = {}
-
- -- Lua version compatibility.
- if _VERSION == 'Lua 5.1' then
- function load(ld, source, mode, env)
- local f, err = loadstring(ld)
- if f and env then return setfenv(f, env) end
- return f, err
- end
- table.unpack = unpack
- end
-
- local newline_sequence, keep_trailing_newline, autoescape = '\n', false, false
- local loader
-
- -- Creates and returns a token pattern with token name *name* and pattern
- -- *patt*.
- -- The returned pattern captures three values: the token's position and name,
- -- and either a string value or table of capture values.
- -- Tokens are used to construct an Abstract Syntax Tree (AST) for a template.
- -- @param name The name of the token.
- -- @param patt The pattern to match. It must contain only one capture: either a
- -- string or table of captures.
- -- @see evaluate
- local function token(name, patt) return Cp() * Cc(name) * patt end
-
- -- Returns an LPeg pattern that immediately raises an error with message
- -- *errmsg* for invalid syntax when parsing a template.
- -- @param errmsg The error message to raise an error with.
- local function lpeg_error(errmsg)
- return P(function(input, index)
- input = input:sub(1, index)
- local _, line_num = input:gsub('\n', '')
- local col_num = #input:match('[^\n]*$')
- error(string.format('Parse Error in file "%s" on line %d, column %d: %s',
- M._FILENAME, line_num + 1, col_num, errmsg), 0)
- end)
- end
-
- ---
- -- Configures the basic delimiters and options for templates.
- -- This function then regenerates the grammar for parsing templates.
- -- Note: this function cannot be used iteratively to configure Lupa options.
- -- Any options not provided are reset to their default values.
- -- @param ts The tag start delimiter. The default value is '{%'.
- -- @param te The tag end delimiter. The default value is '%}'.
- -- @param vs The variable start delimiter. The default value is '{{'.
- -- @param ve The variable end delimiter. The default value is '}}'.
- -- @param cs The comment start delimiter. The default value is '{#'.
- -- @param ce The comment end delimiter. The default value is '#}'.
- -- @param options Optional set of options for templates:
- --
- -- * `trim_blocks`: Trim the first newline after blocks.
- -- * `lstrip_blocks`: Strip line-leading whitespace in front of tags.
- -- * `newline_sequence`: The end-of-line character to use.
- -- * `keep_trailing_newline`: Whether or not to keep a newline at the end of
- -- a template.
- -- * `autoescape`: Whether or not to autoescape HTML entities. May be a
- -- function that accepts the template's filename as an argument and returns
- -- a boolean.
- -- * `loader`: Function that receives a template name to load and returns the
- -- path to that template.
- -- @name configure
- function M.configure(ts, te, vs, ve, cs, ce, options)
- if type(ts) == 'table' then options, ts = ts, nil end
- if not ts then ts = '{%' end
- if not te then te = '%}' end
- if not vs then vs = '{{' end
- if not ve then ve = '}}' end
- if not cs then cs = '{#' end
- if not ce then ce = '#}' end
-
- -- Tokens for whitespace control.
- local lstrip = token('lstrip', C('-')) + '+' -- '+' is handled by grammar
- local rstrip = token('rstrip', -(P(te) + ve + ce) * C('-'))
-
- -- Configure delimiters, including whitespace control.
- local tag_start = P(ts) * lstrip^-1 * space^0
- local tag_end = space^0 * rstrip^-1 * P(te)
- local variable_start = P(vs) * lstrip^-1 * space^0
- local variable_end = space^0 * rstrip^-1 * P(ve)
- local comment_start = P(cs) * lstrip^-1 * space^0
- local comment_end = space^0 * rstrip^-1 * P(ce)
- if options and options.trim_blocks then
- -- Consider whitespace, including a newline, immediately following a tag as
- -- part of that tag so it is not captured as plain text. Basically, strip
- -- the trailing newline from tags.
- tag_end = tag_end * S(' \t')^0 * newline^-1
- comment_end = comment_end * S(' \t')^0 * newline^-1
- end
-
- -- Error messages.
- local variable_end_error = lpeg_error('"'..ve..'" expected')
- local comment_end_error = lpeg_error('"'..ce..'" expected')
- local tag_end_error = lpeg_error('"'..te..'" expected')
- local endraw_error = lpeg_error('additional tag or "'..ts..' endraw '..te..
- '" expected')
- local expr_error = lpeg_error('expression expected')
- local endblock_error = lpeg_error('additional tag or "'..ts..' endblock '..
- te..'" expected')
- local endfor_error = lpeg_error('additional tag or "'..ts..' endfor '..te..
- '" expected')
- local endif_error = lpeg_error('additional tag or "'..ts..' endif '..te..
- '" expected')
- local endmacro_error = lpeg_error('additional tag or "'..ts..' endmacro '..
- te..'" expected')
- local endcall_error = lpeg_error('additional tag or "'..ts..' endcall '..te..
- '" expected')
- local endfilter_error = lpeg_error('additional tag or "'..ts..' endfilter '..
- te..'" expected')
- local tag_error = lpeg_error('unknown or unexpected tag')
- local main_error = lpeg_error('unexpected character; text or tag expected')
-
- -- Grammar.
- M.grammar = Ct(P{
- -- Utility patterns used by tokens.
- entity_start = tag_start + variable_start + comment_start,
- any_text = (1 - V('entity_start'))^1,
- -- Allow '{{' by default in expression text since it is valid in Lua.
- expr_text = (1 - tag_end - tag_start - comment_start)^1,
- -- When `options.lstrip_blocks` is enabled, ignore leading whitespace
- -- immediately followed by a tag (as long as '+' is not present) so that
- -- whitespace not captured as plain text. Basically, strip leading spaces
- -- from tags.
- line_text = (1 - newline - V('entity_start'))^1,
- lstrip_entity_start = -P(vs) * (P(ts) + cs) * -P('+'),
- lstrip_space = S(' \t')^1 * #V('lstrip_entity_start'),
- text_lines = V('line_text') * (newline * -(S(' \t')^0 * V('lstrip_entity_start')) * V('line_text'))^0 * newline^-1 + newline,
-
- -- Plain text.
- text = (not options or not options.lstrip_blocks) and
- token('text', C(V('any_text'))) or
- V('lstrip_space') + token('text', C(V('text_lines'))),
-
- -- Variables: {{ expr }}.
- lua_table = '{' * ((1 - S('{}')) + V('lua_table'))^0 * '}',
- variable = variable_start *
- token('variable', C((V('lua_table') + (1 - variable_end))^0)) *
- (variable_end + variable_end_error),
-
- -- Filters: handled in variable evaluation.
-
- -- Tests: handled in control structure expression evaluation.
-
- -- Comments: {# comment #}.
- comment = comment_start * (1 - comment_end)^0 * (comment_end + comment_end_error),
-
- -- Whitespace control: handled in tag/variable/comment start/end.
-
- -- Escaping: {% raw %} body {% endraw %}.
- raw_block = tag_start * 'raw' * (tag_end + tag_end_error) *
- token('text', C((1 - (tag_start * 'endraw' * tag_end))^0)) *
- (tag_start * 'endraw' * tag_end + endraw_error),
-
- -- Note: line statements are not supported since this grammer cannot parse
- -- Lua itself.
-
- -- Template inheritence.
- -- {% block ... %} body {% endblock %}
- block_block = tag_start * 'block' * space^1 * token('block', Ct((Cg(V('expr_text'), 'expression') + expr_error) * (tag_end + tag_end_error) *
- V('body')^-1)) *
- (tag_start * 'endblock' * tag_end + endblock_error),
- -- {% extends ... %}
- extends_tag = tag_start * 'extends' * space^1 * token('extends', C(V('expr_text')) + expr_error) * (tag_end + tag_end_error),
- -- Super blocks are handled in variables.
- -- Note: named block end tags are not supported since keeping track of that
- -- state information is difficult.
- -- Note: block nesting and scope is not applicable since blocks always have
- -- access to scoped variables in this implementation.
-
- -- Control Structures.
- -- {% for expr %} body {% else %} body {% endfor %}
- for_block = tag_start * 'for' * space^1 * token('for', Ct((Cg(V('expr_text'), 'expression') + expr_error) * (tag_end + tag_end_error) *
- V('body')^-1 *
- Cg(Ct(tag_start * 'else' * tag_end *
- V('body')^-1), 'else')^-1)) *
- (tag_start * 'endfor' * tag_end + endfor_error),
- -- {% if expr %} body {% elseif expr %} body {% else %} body {% endif %}
- if_block = tag_start * 'if' * space^1 * token('if', Ct((Cg(V('expr_text'), 'expression') + expr_error) * (tag_end + tag_end_error) *
- V('body')^-1 *
- Cg(Ct(Ct(tag_start * 'elseif' * space^1 * (Cg(V('expr_text'), 'expression') + expr_error) * (tag_end + tag_end_error) *
- V('body')^-1)^1), 'elseif')^-1 *
- Cg(Ct(tag_start * 'else' * tag_end *
- V('body')^-1), 'else')^-1)) *
- (tag_start * 'endif' * tag_end + endif_error),
- -- {% macro expr %} body {% endmacro %}
- macro_block = tag_start * 'macro' * space^1 * token('macro', Ct((Cg(V('expr_text'), 'expression') + expr_error) * (tag_end + tag_end_error) *
- V('body')^-1)) *
- (tag_start * 'endmacro' * tag_end + endmacro_error),
- -- {% call expr %} body {% endcall %}
- call_block = tag_start * 'call' * (space^1 + #P('(')) * token('call', Ct((Cg(V('expr_text'), 'expression') + expr_error) * (tag_end + tag_end_error) *
- V('body')^-1)) *
- (tag_start * 'endcall' * tag_end + endcall_error),
- -- {% filter expr %} body {% endfilter %}
- filter_block = tag_start * 'filter' * space^1 * token('filter', Ct((Cg(V('expr_text'), 'expression') + expr_error) * (tag_end + tag_end_error) *
- V('body')^-1)) *
- (tag_start * 'endfilter' * tag_end + endfilter_error),
- -- {% set ... %}
- set_tag = tag_start * 'set' * space^1 * token('set', C(V('expr_text')) + expr_error) * (tag_end + tag_end_error),
- -- {% include ... %}
- include_tag = tag_start * 'include' * space^1 * token('include', C(V('expr_text')) + expr_error) * (tag_end + tag_end_error),
- -- {% import ... %}
- import_tag = tag_start * 'import' * space^1 * token('import', C(V('expr_text')) + expr_error) * (tag_end + tag_end_error),
-
- -- Note: i18n is not supported since it is out of scope for this
- -- implementation.
-
- -- Expression statement: {% do ... %}.
- do_tag = tag_start * 'do' * space^1 * token('do', C(V('expr_text')) + expr_error) * (tag_end + tag_end_error),
-
- -- Note: loop controls are not supported since that would require jumping
- -- between "scopes" (e.g. from within an "if" block to outside that "if"
- -- block's parent "for" block when coming across a {% break %} tag).
-
- -- Note: with statement is not supported since it is out of scope for this
- -- implementation.
-
- -- Note: autoescape is not supported since it is out of scope for this
- -- implementation.
-
- -- Any valid blocks of text or tags.
- body = (V('text') + V('variable') + V('comment') + V('raw_block') +
- V('block_block') + V('extends_tag') + V('for_block') +
- V('if_block') + V('macro_block') + V('call_block') +
- V('filter_block') + V('set_tag') + V('include_tag') +
- V('import_tag') + V('do_tag'))^0,
-
- -- Main pattern.
- V('body') * (-1 + tag_start * tag_error + main_error),
- })
-
- -- Other options.
- if options and options.newline_sequence then
- assert(options.newline_sequence:find('^\r?\n$'),
- 'options.newline_sequence must be "\r\n" or "\n"')
- newline_sequence = options.newline_sequence
- else
- newline_sequence = '\n'
- end
- if options and options.keep_trailing_newline then
- keep_trailing_newline = options.keep_trailing_newline
- else
- keep_trailing_newline = false
- end
- if options and options.autoescape then
- autoescape = options.autoescape
- else
- autoescape = false
- end
- if options and options.loader then
- assert(type(options.loader) == 'function',
- 'options.loader must be a function that returns a filename')
- loader = options.loader
- else
- loader = M.loaders.filesystem()
- end
- end
-
- -- Wraps Lua's `assert()` in template environment *env* such that, when called
- -- in conjunction with another Lua function that produces an error message (e.g.
- -- `load()` and `pcall()`), that error message's context (source and line
- -- number) is replaced by the template's context.
- -- This results in Lua's error messages pointing to a template position rather
- -- than this library's source code.
- -- @param env The environment for the currently running template. It must have
- -- a `_SOURCE` field with the template's source text and a `_POSITION` field
- -- with the current position of expansion.
- -- @param ... Arguments to Lua's `assert()`.
- local function env_assert(env, ...)
- if not select(1, ...) then
- local input = env._LUPASOURCE:sub(1, env._LUPAPOSITION)
- local _, line_num = input:gsub('\n', '')
- local col_num = #input:match('[^\n]*$')
- local errmsg = select(2, ...)
- errmsg = errmsg:match(':%d+: (.*)$') or errmsg -- reformat if necessary
- error(string.format('Runtime Error in file "%s" on line %d, column %d: %s',
- env._LUPAFILENAME, line_num + 1, col_num, errmsg), 0)
- end
- return ...
- end
-
- -- Returns a generator that returns the position and filter in a list of
- -- filters, taking into account '|'s that may be within filter arguments.
- -- @usage for pos, filter in each_filter('foo|join("|")|bar') do ... end
- local function each_filter(s)
- local init = 1
- return function(s)
- local pos, filter, e = s:match('^%s*()([^|(]+%b()[^|]*)|?()', init)
- if not pos then pos, filter, e = s:match('()([^|]+)|?()', init) end
- init = e
- return pos, filter
- end, s
- end
-
- -- Evaluates template variable *expression* subject to template environment
- -- *env*, applying any filters given in *expression*.
- -- @param expression The string expression to evaluate.
- -- @param env The environment to evaluate the expression in.
- local function eval(expression, env)
- local expr, pos, filters = expression:match('^([^|]*)|?()(.-)$')
- -- Evaluate base expression.
- local f = env_assert(env, load('return '..expr, nil, nil, env))
- local result = select(2, env_assert(env, pcall(f)))
- -- Apply any filters.
- local results, multiple_results = nil, false
- local p = env._LUPAPOSITION + pos - 1 -- mark position at first filter
- for pos, filter in each_filter(filters) do
- env._LUPAPOSITION = p + pos - 1 -- update position for error messages
- local name, params = filter:match('^%s*([%w_]+)%(?(.-)%)?%s*$')
- f = M.filters[name]
- env_assert(env, f, 'unknown filter "'..name..'"')
- local args = env_assert(env, load('return {'..params..'}', nil, nil, env),
- 'invalid filter parameter(s) for "'..name..'"')()
- if not multiple_results then
- results = {select(2,
- env_assert(env, pcall(f, result, table.unpack(args))))}
- else
- for i = 1, #results do table.insert(args, i, results[i]) end
- results = {select(2, env_assert(env, pcall(f, table.unpack(args))))}
- end
- result, multiple_results = results[1], #results > 1
- end
- if multiple_results then return table.unpack(results) end
- return result
- end
-
- local iterate
-
- -- Iterates over *ast*, a collection of tokens from a portion of a template's
- -- Abstract Syntax Tree (AST), evaluating any expressions in template
- -- environment *env*, and returns a concatenation of the results.
- -- @param ast A template's AST or portion of its AST (e.g. portion inside a
- -- 'for' control structure).
- -- @param env Environment to evaluate any expressions in.
- local function evaluate(ast, env)
- local chunks = {}
- local extends -- text of a parent template
- local rstrip -- flag for stripping leading whitespace of next token
- for i = 1, #ast, 3 do
- local pos, token, block = ast[i], ast[i + 1], ast[i + 2]
- env._LUPAPOSITION = pos
- if token == 'text' then
- chunks[#chunks + 1] = block
- elseif token == 'variable' then
- local value = eval(block, env)
- if autoescape then
- local escape = autoescape
- if type(autoescape) == 'function' then
- escape = autoescape(env._LUPAFILENAME) -- TODO: test
- end
- if escape and type(value) == 'string' then
- value = M.filters.escape(value)
- end
- end
- chunks[#chunks + 1] = value ~= nil and tostring(value) or ''
- elseif token == 'extends' then
- env_assert(env, not extends,
- 'cannot have multiple "extends" in the same scope')
- local file = eval(block, env) -- covers strings and variables
- extends = file
- env._LUPAEXTENDED = true -- used by parent templates
- elseif token == 'block' then
- local name = block.expression:match('^[%w_]+$')
- env_assert(env, name, 'invalid block name')
- -- Store the block for potential use by the parent template if this
- -- template is a child template, or for use by `self`.
- if not env._LUPABLOCKS then env._LUPABLOCKS = {} end
- if not env._LUPABLOCKS[name] then env._LUPABLOCKS[name] = {} end
- table.insert(env._LUPABLOCKS[name], 1, block)
- -- Handle the block properly.
- if not extends then
- if not env._LUPAEXTENDED then
- -- Evaluate the block normally.
- chunks[#chunks + 1] = evaluate(block, env)
- else
- -- A child template is overriding this parent's named block. Evaluate
- -- the child's block and use it instead of the parent's.
- local blocks = env._LUPABLOCKS[name]
- local super_env = setmetatable({super = function()
- -- Loop through the chain of defined blocks, evaluating from top to
- -- bottom, and return the bottom block. In each sub-block, the
- -- 'super' variable needs to point to the next-highest block's
- -- evaluated result.
- local super = evaluate(block, env) -- start with parent block
- local sub_env = setmetatable({super = function() return super end},
- {__index = env})
- for i = 1, #blocks - 1 do super = evaluate(blocks[i], sub_env) end
- return super
- end}, {__index = env})
- chunks[#chunks + 1] = evaluate(blocks[#blocks], super_env)
- end
- end
- elseif token == 'for' then
- local expr = block.expression
- local p = env._LUPAPOSITION -- mark position at beginning of expression
- -- Extract variable list and generator.
- local patt = '^([%w_,%s]+)%s+in%s+()(.+)%s+if%s+(.+)$'
- local var_list, pos, generator, if_expr = expr:match(patt)
- if not var_list then
- var_list, pos, generator = expr:match('^([%w_,%s]+)%s+in%s+()(.+)$')
- end
- env_assert(env, var_list and generator, 'invalid for expression')
- -- Store variable names in a list for loop assignment.
- local variables = {}
- for variable, pos in var_list:gmatch('([^,%s]+)()') do
- env._LUPAPOSITION = p + pos - 1 -- update position for error messages
- env_assert(env, variable:find('^[%a_]') and variable ~= 'loop',
- 'invalid variable name')
- variables[#variables + 1] = variable
- end
- -- Evaluate the generator and perform the iteration.
- env._LUPAPOSITION = p + pos - 1 -- update position to generator
- if not generator:find('|') then
- generator = env_assert(env, load('return '..generator, nil, nil, env))
- else
- local generator_expr = generator
- generator = function() return eval(generator_expr, env) end
- end
- local new_env = setmetatable({}, {__index = env})
- chunks[#chunks + 1] = iterate(generator, variables, if_expr, block,
- new_env, 1, ast[i + 4] == 'lstrip')
- elseif token == 'if' then
- if eval(block.expression, env) then
- chunks[#chunks + 1] = evaluate(block, env)
- else
- local evaluate_else = true
- local elseifs = block['elseif']
- if elseifs then
- for j = 1, #elseifs do
- if eval(elseifs[j].expression, env) then
- chunks[#chunks + 1] = evaluate(elseifs[j], env)
- evaluate_else = false
- break
- end
- end
- end
- if evaluate_else and block['else'] then
- chunks[#chunks + 1] = evaluate(block['else'], env)
- end
- end
- elseif token == 'macro' then
- -- Parse the macro's name and parameter list.
- local signature = block.expression
- local name, param_list = signature:match('^([%w_]+)(%b())')
- env_assert(env, name and param_list, 'invalid macro expression')
- param_list = param_list:sub(2, -2)
- local p = env._LUPAPOSITION + #name + 1 -- mark pos at beginning of args
- local params, defaults = {}, {}
- for param, pos, default in param_list:gmatch('([%w_]+)=?()([^,]*)') do
- params[#params + 1] = param
- if default ~= '' then
- env._LUPAPOSITION = p + pos - 1 -- update position for error messages
- local f = env_assert(env, load('return '..default))
- defaults[param] = select(2, env_assert(env, pcall(f)))
- end
- end
- -- Create the function associated with the macro such that when the
- -- function is called (from within {{ ... }}), the macro's body is
- -- evaluated subject to an environment where parameter names are variables
- -- whose values are the ones passed to the macro itself.
- env[name] = function(...)
- local new_env = setmetatable({}, {__index = function(_, k)
- if k == 'caller' and type(env[k]) ~= 'function' then return nil end
- return env[k]
- end})
- local args = {...}
- -- Assign the given parameter values.
- for i = 1, #args do
- if i > #params then break end
- new_env[params[i]] = args[i]
- end
- -- Clear all other unspecified parameter values or set them to their
- -- defined defaults.
- for i = #args + 1, #params do
- new_env[params[i]] = defaults[params[i]]
- end
- -- Store extra parameters in "varargs" variable.
- new_env.varargs = {}
- for i = #params + 1, #args do
- new_env.varargs[#new_env.varargs + 1] = args[i]
- end
- local chunk = evaluate(block, new_env)
- if ast[i + 4] == 'lstrip' then chunk = chunk:gsub('%s*$', '') end
- return chunk
- end
- elseif token == 'call' then
- -- Parse the call block's parameter list (if any) and determine the macro
- -- to call.
- local param_list = block.expression:match('^(%b())')
- local params = {}
- if param_list then
- for param in param_list:gmatch('[%w_]+') do
- params[#params + 1] = param
- end
- end
- local macro = block.expression:match('^%b()(.+)$') or block.expression
- -- Evaluate the given macro, subject to a "caller" function that returns
- -- the contents of this call block. Any arguments passed to the caller
- -- function are used as values of this parameters parsed earlier.
- local old_caller = M.env.caller -- save
- M.env.caller = function(...)
- local new_env = setmetatable({}, {__index = env})
- local args = {...}
- -- Assign the given parameter values (if any).
- for i = 1, #args do new_env[params[i]] = args[i] end
- local chunk = evaluate(block, new_env)
- if ast[i + 4] == 'lstrip' then chunk = chunk:gsub('%s*$', '') end
- return chunk
- end
- chunks[#chunks + 1] = eval(macro, env)
- M.env.caller = old_caller -- restore
- elseif token == 'filter' then
- local text = evaluate(block, env)
- local p = env._LUPAPOSITION -- mark position at beginning of expression
- for pos, filter in each_filter(block.expression) do
- env._LUPAPOSITION = p + pos - 1 -- update position for error messages
- local name, params = filter:match('^%s*([%w_]+)%(?(.-)%)?%s*$')
- local f = M.filters[name]
- env_assert(env, f, 'unknown filter "'..name..'"')
- local args = env_assert(env, load('return {'..params..'}'),
- 'invalid filter parameter(s) for "'..name..
- '"')()
- text = select(2, env_assert(env, pcall(f, text, table.unpack(args))))
- end
- chunks[#chunks + 1] = text
- elseif token == 'set' then
- local var, expr = block:match('^([%a_][%w_]*)%s*=%s*(.+)$')
- env_assert(env, var and expr, 'invalid variable name or expression')
- env[var] = eval(expr, env)
- elseif token == 'do' then
- env_assert(env, pcall(env_assert(env, load(block, nil, nil, env))))
- elseif token == 'include' then
- -- Parse the include block for flags.
- local without_context = block:find('without%s+context%s*')
- local ignore_missing = block:find('ignore%s+missing%s*')
- block = block:gsub('witho?u?t?%s+context%s*', '')
- :gsub('ignore%s+missing%s*', '')
- -- Evaluate the include expression in order to determine the file to
- -- include. If the result is a table, use the first file that exists.
- local file = eval(block, env) -- covers strings and variables
- if type(file) == 'table' then
- local files = file
- for i = 1, #files do
- file = loader(files[i], env)
- if file then break end
- end
- if type(file) == 'table' then file = nil end
- elseif type(file) == 'string' then
- file = loader(file, env)
- else
- error('"include" requires a string or table of files')
- end
- -- If the file exists, include it. Otherwise throw an error unless the
- -- "ignore missing" flag was given.
- env_assert(env, file or ignore_missing, 'no file(s) found to include')
- if file then
- chunks[#chunks + 1] = M.expand_file(file, not without_context and env or
- M.env)
- end
- elseif token == 'import' then
- local file, global = block:match('^%s*(.+)%s+as%s+([%a][%w_]*)%s*')
- local new_env = setmetatable({}, {
- __index = block:find('with%s+context%s*$') and env or M.env
- })
- M.expand_file(eval(file or block, env), new_env)
- -- Copy any defined macros and variables over into the proper namespace.
- if global then env[global] = {} end
- local namespace = global and env[global] or env
- for k, v in pairs(new_env) do if not env[k] then namespace[k] = v end end
- elseif token == 'lstrip' and chunks[#chunks] then
- chunks[#chunks] = chunks[#chunks]:gsub('%s*$', '')
- elseif token == 'rstrip' then
- rstrip = true -- can only strip after determining the next chunk
- end
- if rstrip and token ~= 'rstrip' then
- chunks[#chunks] = chunks[#chunks]:gsub('^%s*', '')
- rstrip = false
- end
- end
- return not extends and table.concat(chunks) or M.expand_file(extends, env)
- end
-
- local pairs_gen, ipairs_gen = pairs({}), ipairs({})
-
- -- Iterates over the generator *generator* subject to string "if" expression
- -- *if_expr*, assigns that generator's returned values to the variable names
- -- listed in *variables* within template environment *env*, evaluates any
- -- expressions in *block* (a portion of a template's AST), and returns a
- -- concatenation of the results.
- -- @param generator Either a function that returns a generator function, or a
- -- table to iterate over. In the latter case, `ipairs()` is used as the
- -- generator function.
- -- @param variables List of variable names to assign values returned by
- -- *generator* to.
- -- @param if_expr A conditional expression that when `false`, skips the current
- -- loop item.
- -- @param block The portion inside the 'for' structure of a template's AST to
- -- iterate with.
- -- @param env The environment iteration variables are defined in and where
- -- expressions are evaluated in.
- -- @param depth The current recursion depth. Recursion is performed by calling
- -- `loop(t)` with a table to iterate over.
- -- @param lstrip Whether or not the "endfor" block strips whitespace on the
- -- left. When `true`, all blocks produced by iteration are left-stripped.
- iterate = function(generator, variables, if_expr, block, env, depth, lstrip)
- local chunks = {}
- local orig_variables = {} -- used to store original loop variables' values
- for i = 1, #variables do orig_variables[variables[i]] = env[variables[i]] end
- local i, n = 1 -- used for loop variables
- local _, s, v -- state variables
- if type(generator) == 'function' then
- _, generator, s, v = env_assert(env, pcall(generator))
- -- In practice, a generator's state variable is normally unused and hidden.
- -- This is not the case for 'pairs()' and 'ipairs', though.
- if variables[1] ~= '_index' and generator ~= pairs_gen and
- generator ~= ipairs_gen then
- table.insert(variables, 1, '_index')
- end
- end
- if type(generator) == 'table' then
- n = #generator
- generator, s, v = ipairs(generator)
- -- "for x in y" translates to "for _, x in ipairs(y)"; hide _ state variable
- if variables[1] ~= '_index' then table.insert(variables, 1, '_index') end
- end
- if generator then
- local first_results -- for preventing infinite loop from invalid generator
- while true do
- local results = {generator(s, v)}
- if results[1] == nil then break end
- -- If the results from the generator look like results returned by a
- -- generator itself (function, state, initial variable), verify last two
- -- results are different. If they are the same, then the original
- -- generator is invalid and will loop infinitely.
- if first_results == nil then
- first_results = #results == 3 and type(results[1]) == 'function' and
- results
- elseif first_results then
- env_assert(env, results[3] ~= first_results[3] or
- results[2] ~= first_results[2],
- 'invalid generator (infinite loop)')
- end
- -- Assign context variables and evaluate the body of the loop.
- -- As long as the result (ignoring the _index variable) is not a single
- -- table and there is only one loop variable defined (again, ignoring
- -- _index variable), assignment occurs as normal in Lua. Otherwise,
- -- unpacking on the table is done (like assignment to ...).
- if not (type(results[2]) == 'table' and #results == 2 and
- #variables > 2) then
- for j = 1, #variables do env[variables[j]] = results[j] end
- else
- for j = 2, #variables do env[variables[j]] = results[2][j - 1] end
- end
- if not if_expr or eval(if_expr, env) then
- env.loop = setmetatable({
- index = i, index0 = i - 1,
- revindex = n and n - (i - 1), revindex0 = n and n - i,
- first = i == 1, last = i == n, length = n,
- cycle = function(...)
- return select((i - 1) % select('#', ...) + 1, ...)
- end,
- depth = depth, depth0 = depth - 1
- }, {__call = function(_, t)
- return iterate(t, variables, if_expr, block, env, depth + 1, lstrip)
- end})
- chunks[#chunks + 1] = evaluate(block, env)
- if lstrip then chunks[#chunks] = chunks[#chunks]:gsub('%s*$', '') end
- i = i + 1
- end
- -- Prepare for next iteration.
- v = results[1]
- end
- end
- if i == 1 and block['else'] then
- chunks[#chunks + 1] = evaluate(block['else'], env)
- end
- for i = 1, #variables do env[variables[i]] = orig_variables[variables[i]] end
- return table.concat(chunks)
- end
-
- -- Expands string template *template* from source *source*, subject to template
- -- environment *env*, and returns the result.
- -- @param template String template to expand.
- -- @param env Environment for the given template.
- -- @param source Filename or identifier the template comes from for error
- -- messages and debugging.
- local function expand(template, env, source)
- template = template:gsub('\r?\n', newline_sequence) -- normalize
- if not keep_trailing_newline then template = template:gsub('\r?\n$', '') end
- -- Set up environment.
- if not env then env = {} end
- if not getmetatable(env) then env = setmetatable(env, {__index = M.env}) end
- env.self = setmetatable({}, {__index = function(_, k)
- env_assert(env, env._LUPABLOCKS and env._LUPABLOCKS[k],
- 'undefined block "'..k..'"')
- return function() return evaluate(env._LUPABLOCKS[k][1], env) end
- end})
- -- Set context variables and expand the template.
- env._LUPASOURCE, env._LUPAFILENAME = template, source
- M._FILENAME = source -- for lpeg errors only
- local ast = assert(lpeg.match(M.grammar, template), "internal error")
- local result = evaluate(ast, env)
- return result
- end
-
- ---
- -- Expands the string template *template*, subject to template environment
- -- *env*, and returns the result.
- -- @param template String template to expand.
- -- @param env Optional environment for the given template.
- -- @name expand
- function M.expand(template, env) return expand(template, env, '<string>') end
-
- ---
- -- Expands the template within file *filename*, subject to template environment
- -- *env*, and returns the result.
- -- @param filename Filename containing the template to expand.
- -- @param env Optional environment for the template to expand.
- -- @name expand_file
- function M.expand_file(filename, env)
- filename = loader(filename, env) or filename
- local f = (not env or not env._LUPASOURCE) and assert(io.open(filename)) or
- env_assert(env, io.open(filename))
- local template = f:read('*a')
- f:close()
- return expand(template, env, filename)
- end
-
- ---
- -- Returns a loader for templates that uses the filesystem starting at directory
- -- *directory*.
- -- When looking up the template for a given filename, the loader considers the
- -- following: if no template is being expanded, the loader assumes the given
- -- filename is relative to *directory* and returns the full path; otherwise the
- -- loader assumes the given filename is relative to the current template's
- -- directory and returns the full path.
- -- The returned path may be passed to `io.open()`.
- -- @param directory Optional the template root directory. The default value is
- -- ".", which is the current working directory.
- -- @name loaders.filesystem
- -- @see configure
- function M.loaders.filesystem(directory)
- return function(filename, env)
- if not filename then return nil end
- local current_dir = env and env._LUPAFILENAME and
- env._LUPAFILENAME:match('^(.+)[/\\]')
- if not filename:find('^/') and not filename:find('^%a:[/\\]') then
- filename = (current_dir or directory or '.')..'/'..filename
- end
- local f = io.open(filename)
- if not f then return nil end
- f:close()
- return filename
- end
- end
-
- -- Globally defined functions.
-
- ---
- -- Returns a sequence of integers from *start* to *stop*, inclusive, in
- -- increments of *step*.
- -- The complete sequence is generated at once -- no generator is returned.
- -- @param start Optional number to start at. The default value is `1`.
- -- @param stop Number to stop at.
- -- @param step Optional increment between sequence elements. The default value
- -- is `1`.
- -- @name _G.range
- function range(start, stop, step)
- if not stop and not step then stop, start = start, 1 end
- if not step then step = 1 end
- local t = {}
- for i = start, stop, step do t[#t + 1] = i end
- return t
- end
-
- ---
- -- Returns an object that cycles through the given values by calls to its
- -- `next()` function.
- -- A `current` field contains the cycler's current value and a `reset()`
- -- function resets the cycler to its beginning.
- -- @param ... Values to cycle through.
- -- @usage c = cycler(1, 2, 3)
- -- @usage c:next(), c:next() --> 1, 2
- -- @usage c:reset() --> c.current == 1
- -- @name _G.cycler
- function cycler(...)
- local c = {...}
- c.n, c.i, c.current = #c, 1, c[1]
- function c:next()
- local current = self.current
- self.i = self.i + 1
- if self.i > self.n then self.i = 1 end
- self.current = self[self.i]
- return current
- end
- function c:reset() self.i, self.current = 1, self[1] end
- return c
- end
-
- -- Create the default sandbox environment for templates.
- local safe = {
- -- Lua globals.
- '_VERSION', 'ipairs', 'math', 'pairs', 'select', 'tonumber', 'tostring',
- 'type', 'bit32', 'os.date', 'os.time', 'string', 'table', 'utf8',
- -- Lupa globals.
- 'range', 'cycler'
- }
- local sandbox_env = setmetatable({}, {__index = M.tests})
- for i = 1, #safe do
- local v = safe[i]
- if not v:find('%.') then
- sandbox_env[v] = _G[v]
- else
- local mod, func = v:match('^([^.]+)%.(.+)$')
- if not sandbox_env[mod] then sandbox_env[mod] = {} end
- sandbox_env[mod][func] = _G[mod][func]
- end
- end
- sandbox_env._G = sandbox_env
-
- ---
- -- Resets Lupa's default delimiters, options, and environments to their
- -- original default values.
- -- @name reset
- function M.reset()
- M.configure('{%', '%}', '{{', '}}', '{#', '#}')
- M.env = setmetatable({}, {__index = sandbox_env})
- end
- M.reset()
-
- ---
- -- The default template environment.
- -- @class table
- -- @name env
- local env
-
- -- Lupa filters.
-
- ---
- -- Returns the absolute value of number *n*.
- -- @param n The number to compute the absolute value of.
- -- @name filters.abs
- M.filters.abs = math.abs
-
- -- Returns a table that, when indexed with an integer, indexes table *t* with
- -- that integer along with string *attribute*.
- -- This is used by filters that operate on particular attributes of table
- -- elements.
- -- @param t The table to index.
- -- @param attribute The additional attribute to index with.
- local function attr_accessor(t, attribute)
- return setmetatable({}, {__index = function(_, i)
- local value = t[i]
- attribute = tonumber(attribute) or attribute
- if type(attribute) == 'number' then return value[attribute] end
- for k in attribute:gmatch('[^.]+') do value = value[k] end
- return value
- end})
- end
-
- ---
- -- Returns a generator that produces all of the items in table *t* in batches
- -- of size *size*, filling any empty spaces with value *fill*.
- -- Combine this with the "list" filter to produce a list.
- -- @param t The table to split into batches.
- -- @param size The batch size.
- -- @param fill The value to use when filling in any empty space in the last
- -- batch.
- -- @usage expand('{% for i in {1, 2, 3}|batch(2, 0) %}{{ i|string }}
- -- {% endfor %}') --> {1, 2} {3, 0}
- -- @see filters.list
- -- @name filters.batch
- function M.filters.batch(t, size, fill)
- assert(t, 'input to filter "batch" was nil instead of a table')
- local n = #t
- return function(t, i)
- if i > n then return nil end
- local batch = {}
- for j = i, i + size - 1 do batch[j - i + 1] = t[j] end
- if i + size > n and fill then
- for j = n + 1, i + size - 1 do batch[#batch + 1] = fill end
- end
- return i + size, batch
- end, t, 1
- end
-
- ---
- -- Capitalizes string *s*.
- -- The first character will be uppercased, the others lowercased.
- -- @param s The string to capitalize.
- -- @usage expand('{{ "foo bar"|capitalize }}') --> Foo bar
- -- @name filters.capitalize
- function M.filters.capitalize(s)
- assert(s, 'input to filter "capitalize" was nil instead of a string')
- local first, rest = s:match('^(.)(.*)$')
- return first and first:upper()..rest:lower() or s
- end
-
- ---
- -- Centers string *s* within a string of length *width*.
- -- @param s The string to center.
- -- @param width The length of the centered string.
- -- @usage expand('{{ "foo"|center(9) }}') --> " foo "
- -- @name filters.center
- function M.filters.center(s, width)
- assert(s, 'input to filter "center" was nil instead of a string')
- local padding = (width or 80) - #s
- local left, right = math.ceil(padding / 2), math.floor(padding / 2)
- return ("%s%s%s"):format((' '):rep(left), s, (' '):rep(right))
- end
-
- ---
- -- Returns value *value* or value *default*, depending on whether or not *value*
- -- is "true" and whether or not boolean *false_defaults* is `true`.
- -- @param value The value return if "true" or if `false` and *false_defaults*
- -- is `true`.
- -- @param default The value to return if *value* is `nil` or `false` (the latter
- -- applies only if *false_defaults* is `true`).
- -- @param false_defaults Optional flag indicating whether or not to return
- -- *default* if *value* is `false`. The default value is `false`.
- -- @usage expand('{{ false|default("no") }}') --> false
- -- @usage expand('{{ false|default("no", true) }') --> no
- -- @name filters.default
- function M.filters.default(value, default, false_defaults)
- if value == nil or false_defaults and not value then return default end
- return value
- end
-
- ---
- -- Returns a table constructed from table *t* such that each element is a list
- -- that contains a single key-value pair and all elements are sorted according
- -- to string *by* (which is either "key" or "value") and boolean
- -- *case_sensitive*.
- -- @param value The table to sort.
- -- @param case_sensitive Optional flag indicating whether or not to consider
- -- case when sorting string values. The default value is `false`.
- -- @param by Optional string that specifies which of the key-value to sort by,
- -- either "key" or "value". The default value is `"key"`.
- -- @usage expand('{{ {b = 1, a = 2}|dictsort|string }}') --> {{"a", 2},
- -- {"b", 1}}
- -- @name filters.dictsort
- function M.filters.dictsort(t, case_sensitive, by)
- assert(t, 'input to filter "dictsort" was nil instead of a table')
- assert(not by or by == 'key' or by == 'value',
- 'filter "dictsort" can only sort tables by "key" or "value"')
- local i = (not by or by == 'key') and 1 or 2
- local items = {}
- for k, v in pairs(t) do items[#items + 1] = {k, v} end
- table.sort(items, function(a, b)
- a, b = a[i], b[i]
- if not case_sensitive then
- if type(a) == 'string' then a = a:lower() end
- if type(b) == 'string' then b = b:lower() end
- end
- return a < b
- end)
- return items
- end
-
- ---
- -- Returns an HTML-safe copy of string *s*.
- -- @param s String to ensure is HTML-safe.
- -- @usage expand([[{{ '<">&'|e}}]]) --> <">&
- -- @name filters.escape
- function M.filters.escape(s)
- assert(s, 'input to filter "escape" was nil instead of a string')
- return s:gsub('[<>"\'&]', {
- ['<'] = '<', ['>'] = '>', ['"'] = '"', ["'"] = ''',
- ['&'] = '&'
- })
- end
-
- ---
- -- Returns an HTML-safe copy of string *s*.
- -- @param s String to ensure is HTML-safe.
- -- @usage expand([[{{ '<">&'|escape}}]]) --> <">&
- -- @name filters.e
- function M.filters.e(s)
- assert(s, 'input to filter "e" was nil instead of a string')
- return M.filters.escape(s)
- end
-
- ---
- -- Returns a human-readable, decimal (or binary, depending on boolean *binary*)
- -- file size for *bytes* number of bytes.
- -- @param bytes The number of bytes to return the size for.
- -- @param binary Flag indicating whether or not to report binary file size
- -- as opposed to decimal file size. The default value is `false`.
- -- @usage expand('{{ 1000|filesizeformat }}') --> 1.0 kB
- -- @name filters.filesizeformat
- function M.filters.filesizeformat(bytes, binary)
- assert(bytes, 'input to filter "filesizeformat" was nil instead of a number')
- local base = binary and 1024 or 1000
- local units = {
- binary and 'KiB' or 'kB', binary and 'MiB' or 'MB',
- binary and 'GiB' or 'GB', binary and 'TiB' or 'TB',
- binary and 'PiB' or 'PB', binary and 'EiB' or 'EB',
- binary and 'ZiB' or 'ZB', binary and 'YiB' or 'YB'
- }
- if bytes < base then
- return string.format('%d Byte%s', bytes, bytes > 1 and 's' or '')
- else
- local limit, unit
- for i = 1, #units do
- limit, unit = base^(i + 1), units[i]
- if bytes < limit then break end
- end
- return string.format('%.1f %s', (base * bytes / limit), unit)
- end
- end
-
- ---
- -- Returns the first element in table *t*.
- -- @param t The table to get the first element of.
- -- @usage expand('{{ range(10)|first }}') --> 1
- -- @name filters.first
- function M.filters.first(t)
- assert(t, 'input to filter "first" was nil instead of a table')
- return t[1]
- end
-
- ---
- -- Returns value *value* as a float.
- -- This filter only works in Lua 5.3, which has a distinction between floats and
- -- integers.
- -- @param value The value to interpret as a float.
- -- @usage expand('{{ 42|float }}') --> 42.0
- -- @name filters.float
- function M.filters.float(value)
- assert(value, 'input to filter "float" was nil instead of a number')
- return (tonumber(value) or 0) * 1.0
- end
-
- ---
- -- Returns an HTML-safe copy of value *value*, even if *value* was returned by
- -- the "safe" filter.
- -- @param value Value to ensure is HTML-safe.
- -- @usage expand('{% set x = "<div />"|safe %}{{ x|forceescape }}') -->
- -- <div />
- -- @name filters.forceescape
- function M.filters.forceescape(value)
- assert(value, 'input to filter "forceescape" was nil instead of a string')
- return M.filters.escape(tostring(value))
- end
-
- ---
- -- Returns the given arguments formatted according to string *s*.
- -- See Lua's `string.format()` for more information.
- -- @param s The string to format subsequent arguments according to.
- -- @param ... Arguments to format.
- -- @usage expand('{{ "%s,%s"|format("a", "b") }}') --> a,b
- -- @name filters.format
- function M.filters.format(s, ...)
- assert(s, 'input to filter "format" was nil instead of a string')
- return string.format(s, ...)
- end
-
- ---
- -- Returns a generator that produces lists of items in table *t* grouped by
- -- string attribute *attribute*.
- -- @param t The table to group items from.
- -- @param attribute The attribute of items in the table to group by. This may
- -- be nested (e.g. "foo.bar" groups by t[i].foo.bar for all i).
- -- @usage expand('{% for age, group in people|groupby("age") %}...{% endfor %}')
- -- @name filters.groupby
- function M.filters.groupby(t, attribute)
- assert(t, 'input to filter "groupby" was nil instead of a table')
- local n = #t
- local seen = {} -- keep track of groupers in order to avoid duplicates
- return function(t, i)
- if i > n then return nil end
- local ta = attr_accessor(t, attribute)
- -- Determine the next grouper.
- local grouper = ta[i]
- while seen[grouper] do
- i = i + 1
- if i > n then return nil end
- grouper = ta[i]
- end
- seen[grouper] = true
- -- Create and return the group.
- local group = {}
- for j = i, #t do if ta[j] == grouper then group[#group + 1] = t[j] end end
- return i + 1, grouper, group
- end, t, 1
- end
-
- ---
- -- Returns a copy of string *s* with all lines after the first indented by
- -- *width* number of spaces.
- -- If boolean *first_line* is `true`, indents the first line as well.
- -- @param s The string to indent lines of.
- -- @param width The number of spaces to indent lines with.
- -- @param first_line Optional flag indicating whether or not to indent the
- -- first line of text. The default value is `false`.
- -- @usage expand('{{ "foo\nbar"|indent(2) }}') --> "foo\n bar"
- -- @name filters.indent
- function M.filters.indent(s, width, first_line)
- assert(s, 'input to filter "indent" was nil instead of a string')
- local indent = (' '):rep(width)
- return (first_line and indent or '')..s:gsub('([\r\n]+)', '%1'..indent)
- end
-
- ---
- -- Returns value *value* as an integer.
- -- @param value The value to interpret as an integer.
- -- @usage expand('{{ 32.32|int }}') --> 32
- -- @name filters.int
- function M.filters.int(value)
- assert(value, 'input to filter "int" was nil instead of a number')
- return math.floor(tonumber(value) or 0)
- end
-
- ---
- -- Returns a string that contains all the elements in table *t* (or all the
- -- attributes named *attribute* in *t*) separated by string *sep*.
- -- @param t The table to join.
- -- @param sep The string to separate table elements with.
- -- @param attribute Optional attribute of elements to use for joining instead
- -- of the elements themselves. This may be nested (e.g. "foo.bar" joins
- -- `t[i].foo.bar` for all i).
- -- @usage expand('{{ {1, 2, 3}|join("|") }}') --> 1|2|3
- -- @name filters.join
- function M.filters.join(t, sep, attribute)
- assert(t, 'input to filter "join" was nil instead of a table')
- if not attribute then
- local strings = {}
- for i = 1, #t do strings[#strings + 1] = tostring(t[i]) end
- return table.concat(strings, sep)
- end
- local ta = attr_accessor(t, attribute)
- local attributes = {}
- for i = 1, #t do attributes[#attributes + 1] = ta[i] end
- return table.concat(attributes, sep)
- end
-
- ---
- -- Returns the last element in table *t*.
- -- @param t The table to get the last element of.
- -- @usage expand('{{ range(10)|last }}') --> 10
- -- @name filters.last
- function M.filters.last(t)
- assert(t, 'input to filter "last" was nil instead of a table')
- return t[#t]
- end
-
- ---
- -- Returns the length of string or table *value*.
- -- @param value The value to get the length of.
- -- @usage expand('{{ "hello world"|length }}') --> 11
- -- @name filters.length
- function M.filters.length(value)
- assert(value, 'input to filter "length" was nil instead of a table or string')
- return #value
- end
-
- ---
- -- Returns the list of items produced by generator *generator*, subject to
- -- initial state *s* and initial iterator variable *i*.
- -- This filter should only be used after a filter that returns a generator.
- -- @param generator Generator function that produces an item.
- -- @param s Initial state for the generator.
- -- @param i Initial iterator variable for the generator.
- -- @usage expand('{{ range(4)|batch(2)|list|string }}') --> {{1, 2}, {3, 4}}
- -- @see filters.batch
- -- @see filters.groupby
- -- @see filters.slice
- -- @name filters.list
- function M.filters.list(generator, s, i)
- assert(type(generator) == 'function',
- 'input to filter "list" must be a generator')
- local list = {}
- for _, v in generator, s, i do list[#list + 1] = v end
- return list
- end
-
- ---
- -- Returns a copy of string *s* with all lowercase characters.
- -- @param s The string to lowercase.
- -- @usage expand('{{ "FOO"|lower }}') --> foo
- -- @name filters.lower
- function M.filters.lower(s)
- assert(s, 'input to filter "lower" was nil instead of a string')
- return string.lower(s)
- end
-
- ---
- -- Maps each element of table *t* to a value produced by filter name *filter*
- -- and returns the resultant table.
- -- @param t The table of elements to map.
- -- @param filter The name of the filter to pass table elements through.
- -- @param ... Any arguments for the filter.
- -- @usage expand('{{ {"1", "2", "3"}|map("int")|sum }}') --> 6
- -- @name filters.map
- function M.filters.map(t, filter, ...)
- assert(t, 'input to filter "map" was nil instead of a table')
- local f = M.filters[filter]
- assert(f, 'unknown filter "'..filter..'"')
- local map = {}
- for i = 1, #t do map[i] = f(t[i], ...) end
- return map
- end
-
- ---
- -- Maps the value of each element's string *attribute* in table *t* to the
- -- value produced by filter name *filter* and returns the resultant table.
- -- @param t The table of elements with attributes to map.
- -- @param attribute The attribute of elements in the table to filter. This may
- -- be nested (e.g. "foo.bar" maps t[i].foo.bar for all i).
- -- @param filter The name of the filter to pass table elements through.
- -- @param ... Any arguments for the filter.
- -- @usage expand('{{ users|mapattr("name")|join("|") }}')
- -- @name filters.mapattr
- function M.filters.mapattr(t, attribute, filter, ...)
- assert(t, 'input to filter "mapattr" was nil instead of a table')
- local ta = attr_accessor(t, attribute)
- local f = M.filters[filter]
- if filter then
- assert(f, 'unknown filter "'..filter..'" given to filter "mapattr"')
- end
- local map = {}
- for i = 1, #t do map[i] = filter and f(ta[i], ...) or ta[i] end
- return map
- end
-
- ---
- -- Returns a random element from table *t*.
- -- @param t The table to get a random element from.
- -- @usage expand('{{ range(100)|random }}')
- -- @name filters.random
- function M.filters.random(t)
- assert(t, 'input to filter "random" was nil instead of a table')
- math.randomseed(os.time())
- return t[math.random(#t)]
- end
-
- ---
- -- Returns a list of elements in table *t* that fail test name *test*.
- -- @param t The table of elements to reject from.
- -- @param test The name of the test to use on table elements.
- -- @param ... Any arguments for the test.
- -- @usage expand('{{ range(5)|reject(is_odd)|join("|") }}') --> 2|4
- -- @name filters.reject
- function M.filters.reject(t, test, ...)
- assert(t, 'input to filter "reject" was nil instead of a table')
- local f = test or function(value) return not not value end
- local items = {}
- for i = 1, #t do if not f(t[i], ...) then items[#items + 1] = t[i] end end
- return items
- end
-
- ---
- -- Returns a list of elements in table *t* whose string attribute *attribute*
- -- fails test name *test*.
- -- @param t The table of elements to reject from.
- -- @param attribute The attribute of items in the table to reject from. This
- -- may be nested (e.g. "foo.bar" tests t[i].foo.bar for all i).
- -- @param test The name of the test to use on table elements.
- -- @param ... Any arguments for the test.
- -- @usage expand('{{ users|rejectattr("offline")|mapattr("name")|join(",") }}')
- -- @name filters.rejectattr
- function M.filters.rejectattr(t, attribute, test, ...)
- assert(t, 'input to filter "rejectattr" was nil instead of a table')
- local ta = attr_accessor(t, attribute)
- local f = test or function(value) return not not value end
- local items = {}
- for i = 1, #t do if not f(ta[i], ...) then items[#items + 1] = t[i] end end
- return items
- end
-
- ---
- -- Returns a copy of string *s* with all (or up to *n*) occurrences of string
- -- *old* replaced by string *new*.
- -- Identical to Lua's `string.gsub()` and handles Lua patterns.
- -- @param s The subject string.
- -- @param pattern The string or Lua pattern to replace.
- -- @param repl The replacement text (may contain Lua captures).
- -- @param n Optional number indicating the maximum number of replacements to
- -- make. The default value is `nil`, which is unlimited.
- -- @usage expand('{% filter upper|replace("FOO", "foo") %}foobar
- -- {% endfilter %}') --> fooBAR
- -- @name filters.replace
- function M.filters.replace(s, pattern, repl, n)
- assert(s, 'input to filter "replace" was nil instead of a string')
- return string.gsub(s, pattern, repl, n)
- end
-
- ---
- -- Returns a copy of the given string or table *value* in reverse order.
- -- @param value The value to reverse.
- -- @usage expand('{{ {1, 2, 3}|reverse|string }}') --> {3, 2, 1}
- -- @name filters.reverse
- function M.filters.reverse(value)
- assert(type(value) == 'table' or type(value) == 'string',
- 'input to filter "reverse" was nil instead of a table or string')
- if type(value) == 'string' then return value:reverse() end
- local t = {}
- for i = 1, #value do t[i] = value[#value - i + 1] end
- return t
- end
-
- ---
- -- Returns number *value* rounded to *precision* decimal places based on string
- -- *method* (if given).
- -- @param value The number to round.
- -- @param precision Optional precision to round the number to. The default
- -- value is `0`.
- -- @param method Optional string rounding method, either `"ceil"` or
- -- `"floor"`. The default value is `nil`, which uses the common rounding
- -- method (if a number's fractional part is 0.5 or greater, rounds up;
- -- otherwise rounds down).
- -- @usage expand('{{ 2.1236|round(3, "floor") }}') --> 2.123
- -- @name filters.round
- function M.filters.round(value, precision, method)
- assert(value, 'input to filter "round" was nil instead of a number')
- assert(not method or method == 'ceil' or method == 'floor',
- 'rounding method given to filter "round" must be "ceil" or "floor"')
- precision = precision or 0
- method = method or (select(2, math.modf(value)) >= 0.5 and 'ceil' or 'floor')
- local s = string.format('%.'..(precision >= 0 and precision or 0)..'f',
- math[method](value * 10^precision) / 10^precision)
- return tonumber(s)
- end
-
- ---
- -- Marks string *s* as HTML-safe, preventing Lupa from modifying it when
- -- configured to autoescape HTML entities.
- -- This filter must be used at the end of a filter chain unless it is
- -- immediately proceeded by the "forceescape" filter.
- -- @param s The string to mark as HTML-safe.
- -- @usage lupa.configure{autoescape = true}
- -- @usage expand('{{ "<div>foo</div>"|safe }}') --> <div>foo</div>
- -- @name filters.safe
- function M.filters.safe(s)
- assert(s, 'input to filter "safe" was nil instead of a string')
- return setmetatable({}, {__tostring = function() return s end})
- end
-
- ---
- -- Returns a list of the elements in table *t* that pass test name *test*.
- -- @param t The table of elements to select from.
- -- @param test The name of the test to use on table elements.
- -- @param ... Any arguments for the test.
- -- @usage expand('{{ range(5)|select(is_odd)|join("|") }}') --> 1|3|5
- -- @name filters.select
- function M.filters.select(t, test, ...)
- assert(t, 'input to filter "select" was nil instead of a table')
- local f = test or function(value) return not not value end
- local items = {}
- for i = 1, #t do if f(t[i], ...) then items[#items + 1] = t[i] end end
- return items
- end
-
- ---
- -- Returns a list of elements in table *t* whose string attribute *attribute*
- -- passes test name *test*.
- -- @param t The table of elements to select from.
- -- @param attribute The attribute of items in the table to select from. This
- -- may be nested (e.g. "foo.bar" tests t[i].foo.bar for all i).
- -- @param test The name of the test to use on table elements.
- -- @param ... Any arguments for the test.
- -- @usage expand('{{ users|selectattr("online")|mapattr("name")|join("|") }}')
- -- @name filters.selectattr
- function M.filters.selectattr(t, attribute, test, ...)
- assert(t, 'input to filter "selectattr" was nil instead of a table')
- local ta = attr_accessor(t, attribute)
- local f = test or function(value) return not not value end
- local items = {}
- for i = 1, #t do if f(ta[i], ...) then items[#items + 1] = t[i] end end
- return items
- end
-
- ---
- -- Returns a generator that produces all of the items in table *t* in *slices*
- -- number of iterations, filling any empty spaces with value *fill*.
- -- Combine this with the "list" filter to produce a list.
- -- @param t The table to slice.
- -- @param slices The number of slices to produce.
- -- @param fill The value to use when filling in any empty space in the last
- -- slice.
- -- @usage expand('{% for i in {1, 2, 3}|slice(2, 0) %}{{ i|string }}
- -- {% endfor %}') --> {1, 2} {3, 0}
- -- @see filters.list
- -- @name filters.slice
- function M.filters.slice(t, slices, fill)
- assert(t, 'input to filter "slice" was nil instead of a table')
- local size, slices_with_extra = math.floor(#t / slices), #t % slices
- return function(t, i)
- if i > slices then return nil end
- local slice = {}
- local s = (i - 1) * size + math.min(i, slices_with_extra + 1)
- local e = i * size + math.min(i, slices_with_extra)
- for j = s, e do slice[j - s + 1] = t[j] end
- if slices_with_extra > 0 and i > slices_with_extra and fill then
- slice[#slice + 1] = fill
- end
- return i + 1, slice
- end, t, 1
- end
-
- ---
- -- Returns a copy of table or string *value* in sorted order by value (or by
- -- an attribute named *attribute*), depending on booleans *reverse* and
- -- *case_sensitive*.
- -- @param value The table or string to sort.
- -- @param reverse Optional flag indicating whether or not to sort in reverse
- -- (descending) order. The default value is `false`, which sorts in ascending
- -- order.
- -- @param case_sensitive Optional flag indicating whether or not to consider
- -- case when sorting string values. The default value is `false`.
- -- @param attribute Optional attribute of elements to sort by instead of the
- -- elements themselves.
- -- @usage expand('{{ {2, 3, 1}|sort|string }}') --> {1, 2, 3}
- -- @name filters.sort
- function M.filters.sort(value, reverse, case_sensitive, attribute)
- assert(value, 'input to filter "sort" was nil instead of a table or string')
- assert(not attribute or type(attribute) == 'string' or
- type(attribute) == 'number',
- 'attribute to filter "sort" must be a string or number')
- local t = {}
- local sort_string = type(value) == 'string'
- if not sort_string then
- for i = 1, #value do t[#t + 1] = value[i] end
- else
- for char in value:gmatch('.') do t[#t + 1] = char end -- chars in string
- end
- table.sort(t, function(a, b)
- if attribute then
- if type(attribute) == 'number' then
- a, b = a[attribute], b[attribute]
- else
- for k in attribute:gmatch('[^.]+') do a, b = a[k], b[k] end
- end
- end
- if not case_sensitive then
- if type(a) == 'string' then a = a:lower() end
- if type(b) == 'string' then b = b:lower() end
- end
- if not reverse then
- return a < b
- else
- return a > b
- end
- end)
- return not sort_string and t or table.concat(t)
- end
-
- ---
- -- Returns the string representation of value *value*, handling lists properly.
- -- @param value Value to return the string representation of.
- -- @usage expand('{{ {1 * 1, 2 * 2, 3 * 3}|string }}') --> {1, 4, 9}
- -- @name filters.string
- function M.filters.string(value)
- if type(value) ~= 'table' then return tostring(value) end
- local t = {}
- for i = 1, #value do
- local item = value[i]
- t[i] = type(item) == 'string' and '"'..item..'"' or M.filters.string(item)
- end
- return '{'..table.concat(t, ', ')..'}'
- end
-
- ---
- -- Returns a copy of string *s* with any HTML tags stripped.
- -- Also cleans up whitespace.
- -- @param s String to strip HTML tags from.
- -- @usage expand('{{ "<div>foo</div>"|striptags }}') --> foo
- -- @name filters.striptags
- function M.filters.striptags(s)
- assert(s, 'input to filter "striptags" was nil instead of a string')
- return s:gsub('%b<>', ''):gsub('%s+', ' '):match('^%s*(.-)%s*$')
- end
-
- ---
- -- Returns the numeric sum of the elements in table *t* or the sum of all
- -- attributes named *attribute* in *t*.
- -- @param t The table to calculate the sum of.
- -- @param attribute Optional attribute of elements to use for summing instead
- -- of the elements themselves. This may be nested (e.g. "foo.bar" sums
- -- `t[i].foo.bar` for all i).
- -- @usage expand('{{ range(6)|sum }}') --> 21
- -- @name filters.sum
- function M.filters.sum(t, attribute)
- assert(t, 'input to filter "sum" was nil instead of a table')
- local ta = attribute and attr_accessor(t, attribute) or t
- local sum = 0
- for i = 1, #t do sum = sum + ta[i] end
- return sum
- end
-
- ---
- -- Returns a copy of all words in string *s* in titlecase.
- -- @param s The string to titlecase.
- -- @usage expand('{{ "foo bar"|title }}') --> Foo Bar
- -- @name filters.title
- function M.filters.title(s)
- assert(s, 'input to filter "title" was nil instead of a string')
- return s:gsub('[^-%s]+', M.filters.capitalize)
- end
-
- ---
- -- Returns a copy of string *s* truncated to *length* number of characters.
- -- Truncated strings end with '...' or string *delimiter*. If boolean
- -- *partial_words* is `false`, truncation will only happen at word boundaries.
- -- @param s The string to truncate.
- -- @param length The length to truncate the string to.
- -- @param partial_words Optional flag indicating whether or not to allow
- -- truncation within word boundaries. The default value is `false`.
- -- @param delimiter Optional delimiter text. The default value is '...'.
- -- @usage expand('{{ "foo bar"|truncate(4) }}') --> "foo ..."
- -- @name filters.truncate
- function M.filters.truncate(s, length, partial_words, delimiter)
- assert(s, 'input to filter "truncate" was nil instead of a string')
- if #s <= length then return s end
- local truncated = s:sub(1, length)
- if s:find('[%w_]', length) and not partial_words then
- truncated = truncated:match('^(.-)[%w_]*$') -- drop partial word
- end
- return truncated..(delimiter or '...')
- end
-
- ---
- -- Returns a copy of string *s* with all uppercase characters.
- -- @param s The string to uppercase.
- -- @usage expand('{{ "foo"|upper }}') --> FOO
- -- @name filters.upper
- function M.filters.upper(s)
- assert(s, 'input to filter "upper" was nil instead of a string')
- return string.upper(s)
- end
-
- ---
- -- Returns a string suitably encoded to be used in a URL from value *value*.
- -- *value* may be a string, table of key-value query parameters, or table of
- -- lists of key-value query parameters (for order).
- -- @param value Value to URL-encode.
- -- @usage expand('{{ {{'f', 1}, {'z', 2}}|urlencode }}') --> f=1&z=2
- -- @name filters.urlencode
- function M.filters.urlencode(value)
- assert(value,
- 'input to filter "urlencode" was nil instead of a string or table')
- if type(value) ~= 'table' then
- return tostring(value):gsub('[^%w.-]', function(c)
- return string.format('%%%X', string.byte(c))
- end)
- end
- local params = {}
- if #value > 0 then
- for i = 1, #value do
- local k = M.filters.urlencode(value[i][1])
- local v = M.filters.urlencode(value[i][2])
- params[#params + 1] = k..'='..v
- end
- else
- for k, v in pairs(value) do
- params[#params + 1] = M.filters.urlencode(k)..'='..M.filters.urlencode(v)
- end
- end
- return table.concat(params, '&')
- end
-
- ---
- -- Replaces any URLs in string *s* with HTML links, limiting link text to
- -- *length* characters.
- -- @param s The string to replace URLs with HTML links in.
- -- @param length Optional maximum number of characters to include in link text.
- -- The default value is `nil`, which imposes no limit.
- -- @param nofollow Optional flag indicating whether or not HTML links will get a
- -- "nofollow" attribute.
- -- @usage expand('{{ "example.com"|urlize }}') -->
- -- <a href="http://example.com">example.com</a>
- -- @name filters.urlize
- function M.filters.urlize(s, length, nofollow)
- assert(s, 'input to filter "urlize" was nil instead of a string')
- -- Trims the given url.
- local function trim_url(url)
- return length and s:sub(1, length)..(#s > length and '...' or '') or url
- end
- local nofollow_attr = nofollow and ' rel="nofollow"' or ''
- local lead, trail = C((S('(<') + '<')^0), C((S('.,)>\n') + '>')^0) * -1
- local middle = C((1 - trail)^0)
- local patt = lpeg.Cs(lead * middle * trail / function(lead, middle, trail)
- local linked
- if middle:find('^www%.') or (not middle:find('@') and
- not middle:find('^https?://') and
- #middle > 0 and middle:find('^%w') and (
- middle:find('%.com$') or
- middle:find('%.net$') or
- middle:find('%.org$')
- )) then
- middle, linked = string.format('<a href="http://%s"%s>%s</a>', middle,
- nofollow_attr, trim_url(middle)), true
- end
- if middle:find('^https?://') then
- middle, linked = string.format('<a href="%s"%s>%s</a>', middle,
- nofollow_attr, trim_url(middle)), true
- end
- if middle:find('@') and not middle:find('^www%.') and
- not middle:find(':') and middle:find('^%S+@[%w._-]+%.[%w._-]+$') then
- middle, linked = string.format('<a href="mailto:%s">%s</a>', middle,
- middle), true
- end
- if linked then return lead..middle..trail end
- end)
- return M.filters.escape(s):gsub('%S+', function(word)
- return lpeg.match(patt, word)
- end)
- end
-
- ---
- -- Returns the number of words in string *s*.
- -- A word is a sequence of non-space characters.
- -- @param s The string to count words in.
- -- @usage expand('{{ "foo bar baz"|wordcount }}') --> 3
- -- @name filters.wordcount
- function M.filters.wordcount(s)
- assert(s, 'input to filter "wordcount" was nil instead of a string')
- return select(2, s:gsub('%S+', ''))
- end
-
- ---
- -- Interprets table *t* as a list of XML attribute-value pairs, returning them
- -- as a properly formatted, space-separated string.
- -- @param t The table of XML attribute-value pairs.
- -- @usage expand('<data {{ {foo = 42, bar = 23}|xmlattr }} />')
- -- @name filters.xmlattr
- function M.filters.xmlattr(t)
- assert(t, 'input to filter "xmlattr" was nil instead of a table')
- local attributes = {}
- for k, v in pairs(t) do
- attributes[#attributes + 1] = string.format('%s="%s"', k,
- M.filters.escape(tostring(v)))
- end
- return table.concat(attributes, ' ')
- end
-
- -- Lupa tests.
-
- ---
- -- Returns whether or not number *n* is odd.
- -- @param n The number to test.
- -- @usage expand('{% for x in range(10) if is_odd(x) %}...{% endif %}')
- -- @name tests.is_odd
- function M.tests.is_odd(n) return n % 2 == 1 end
-
- ---
- -- Returns whether or not number *n* is even.
- -- @param n The number to test.
- -- @usage expand('{% for x in range(10) if is_even(x) %}...{% endif %}')
- -- @name tests.is_even
- function M.tests.is_even(n) return n % 2 == 0 end
-
- ---
- -- Returns whether or not number *n* is evenly divisible by number *num*.
- -- @param n The dividend to test.
- -- @param num The divisor to use.
- -- @usage expand('{% if is_divisibleby(x, y) %}...{% endif %}')
- -- @name tests.is_divisibleby
- function M.tests.is_divisibleby(n, num) return n % num == 0 end
-
- ---
- -- Returns whether or not value *value* is non-nil, and thus defined.
- -- @param value The value to test.
- -- @usage expand('{% if is_defined(x) %}...{% endif %}')
- -- @name tests.is_defined
- function M.tests.is_defined(value) return value ~= nil end
-
- ---
- -- Returns whether or not value *value* is nil, and thus effectively undefined.
- -- @param value The value to test.
- -- @usage expand('{% if is_undefined(x) %}...{% endif %}')
- -- @name tests.is_undefined
- function M.tests.is_undefined(value) return value == nil end
-
- ---
- -- Returns whether or not value *value* is nil.
- -- @param value The value to test.
- -- @usage expand('{% if is_none(x) %}...{% endif %}')
- -- @name tests.is_none
- function M.tests.is_none(value) return value == nil end
-
- ---
- -- Returns whether or not value *value* is nil.
- -- @param value The value to test.
- -- @usage expand('{% if is_nil(x) %}...{% endif %}')
- -- @name tests.is_nil
- function M.tests.is_nil(value) return value == nil end
-
- ---
- -- Returns whether or not string *s* is in all lower-case characters.
- -- @param s The string to test.
- -- @usage expand('{% if is_lower(s) %}...{% endif %}')
- -- @name tests.is_lower
- function M.tests.is_lower(s) return s:lower() == s end
-
- ---
- -- Returns whether or not string *s* is in all upper-case characters.
- -- @param s The string to test.
- -- @usage expand('{% if is_upper(s) %}...{% endif %}')
- -- @name tests.is_upper
- function M.tests.is_upper(s) return s:upper() == s end
-
- ---
- -- Returns whether or not value *value* is a string.
- -- @param value The value to test.
- -- @usage expand('{% if is_string(x) %}...{% endif %}')
- -- @name tests.is_string
- function M.tests.is_string(value) return type(value) == 'string' end
-
- ---
- -- Returns whether or not value *value* is a table.
- -- @param value The value to test.
- -- @usage expand('{% if is_mapping(x) %}...{% endif %}')
- -- @name tests.is_mapping
- function M.tests.is_mapping(value) return type(value) == 'table' end
-
- ---
- -- Returns whether or not value *value* is a table.
- -- @param value The value to test.
- -- @usage expand('{% if is_table(x) %}...{% endif %}')
- -- @name tests.is_table
- function M.tests.is_table(value) return type(value) == 'table' end
-
- ---
- -- Returns whether or not value *value* is a number.
- -- @param value The value to test.
- -- @usage expand('{% if is_number(x) %}...{% endif %}')
- -- @name tests.is_number
- function M.tests.is_number(value) return type(value) == 'number' end
-
- ---
- -- Returns whether or not value *value* is a sequence, namely a table with
- -- non-zero length.
- -- @param value The value to test.
- -- @usage expand('{% if is_sequence(x) %}...{% endif %}')
- -- @name tests.is_sequence
- function M.tests.is_sequence(value)
- return type(value) == 'table' and #value > 0
- end
-
- ---
- -- Returns whether or not value *value* is a sequence (a table with non-zero
- -- length) or a generator.
- -- At the moment, all functions are considered generators.
- -- @param value The value to test.
- -- @usage expand('{% if is_iterable(x) %}...{% endif %}')
- -- @name tests.is_iterable
- function M.tests.is_iterable(value)
- return M.tests.is_sequence(value) or type(value) == 'function'
- end
-
- ---
- -- Returns whether or not value *value* is a function.
- -- @param value The value to test.
- -- @usage expand('{% if is_callable(x) %}...{% endif %}')
- -- @name tests.is_callable
- function M.tests.is_callable(value) return type(value) == 'function' end
-
- ---
- -- Returns whether or not value *value* is the same as value *other*.
- -- @param value The value to test.
- -- @param other The value to compare with.
- -- @usage expand('{% if is_sameas(x, y) %}...{% endif %}')
- -- @name tests.is_sameas
- function M.tests.is_sameas(value, other) return value == other end
-
- ---
- -- Returns whether or not value *value* is HTML-safe.
- -- @param value The value to test.
- -- @usage expand('{% if is_escaped(x) %}...{% endif %}')
- -- @name tests.is_escaped
- function M.tests.is_escaped(value)
- return getmetatable(value) and getmetatable(value).__tostring ~= nil
- end
-
- return M
|