texlive[62254] Master/texmf-dist: penlight (27feb22)

commits+karl at tug.org commits+karl at tug.org
Sun Feb 27 22:34:10 CET 2022


Revision: 62254
          http://tug.org/svn/texlive?view=revision&revision=62254
Author:   karl
Date:     2022-02-27 22:34:10 +0100 (Sun, 27 Feb 2022)
Log Message:
-----------
penlight (27feb22)

Modified Paths:
--------------
    trunk/Master/texmf-dist/doc/luatex/penlight/penlight.pdf
    trunk/Master/texmf-dist/doc/luatex/penlight/penlight.tex
    trunk/Master/texmf-dist/tex/luatex/penlight/penlight.lua
    trunk/Master/texmf-dist/tex/luatex/penlight/penlight.sty
    trunk/Master/texmf-dist/tex/luatex/penlight/penlightextras.lua

Modified: trunk/Master/texmf-dist/doc/luatex/penlight/penlight.pdf
===================================================================
(Binary files differ)

Modified: trunk/Master/texmf-dist/doc/luatex/penlight/penlight.tex
===================================================================
--- trunk/Master/texmf-dist/doc/luatex/penlight/penlight.tex	2022-02-27 21:33:55 UTC (rev 62253)
+++ trunk/Master/texmf-dist/doc/luatex/penlight/penlight.tex	2022-02-27 21:34:10 UTC (rev 62254)
@@ -1,5 +1,5 @@
 % Kale Ewasiuk (kalekje at gmail.com)
-% 2021-12-15
+% 2022-02-27
 % Copyright (C) 2021 Kale Ewasiuk
 %
 % Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -25,7 +25,7 @@
 
 \documentclass[11pt,parskip=half]{scrartcl}
 \setlength{\parindent}{0ex}
-\newcommand{\llcmd}[1]{\leavevmode\llap{\texttt{\detokenize{#1}\ }}}
+\newcommand{\llcmd}[1]{\leavevmode\llap{\texttt{\detokenize{#1}}}}
 \newcommand{\cmd}[1]{\texttt{\detokenize{#1}}}
 \newcommand{\qcmd}[1]{``\cmd{#1}''}
 \usepackage{url}
@@ -52,7 +52,7 @@
 \date{\today}
 
 
-\RequirePackage{penlight}
+\RequirePackage[pl,extras]{penlight}
 \title{penlight}
 \subtitle{Lua libraries for use in LuaLaTeX}
 
@@ -60,7 +60,7 @@
 \maketitle
 
         The official documentation for the Lua library can be found here:\\
-  \mbox{\url{https://stevedonovan.github.io/Penlight/api/manual/01-introduction.md.html#}}
+  \mbox{\url{https://lunarmodules.github.io/Penlight}}
     \\
 
     \subsection*{Required Package Option}
@@ -70,49 +70,92 @@
     \texttt{penlight.XYZ} or \texttt{pl.XYZ}
 
 
+  
+  \subsection*{texlua usage}
+If you want to use Penlight (and extras) with the \texttt{texlua} intrepreter (no document made, only for Lua files, useful for testing),
+you can access it by setting \cmd{__SKIP_TEX__ = true} and adding the package to path. For example:
+ \begin{verbatim}
+package.path = package.path .. ';'..'path/to/texmf/tex/latex/penlight/?.lua'
+penlight = require('penlight')
+-- below is optional
+__SKIP_TEX__ = true  --only required if you want to use
+                     --penlightextras without a LaTeX run
+-- __PL_NO_GLOBALS__ = true -- optional, skip global definitions
+require('penlightextras')
+\end{verbatim}
 
-    \subsection*{Additional Package Options}
+\pagebreak
 
+
+\subsection*{Additional Package Options}
+
     \noindent
-    \begin{tabular}{lp{4.5in}}
+    \hspace*{-6ex}\begin{tabular}{lp{4.8in}}
     \texttt{stringx} & will import additional string functions into the string meta table.\\
                     & this will be ran in pre-amble: \texttt{require('pl.stringx').import()}\\
-                        & \hspace*{-4em}\url{https://stevedonovan.github.io/Penlight/api/libraries/pl.stringx.html}\\\\
     \texttt{format} & allows \% operator for Python-style string formating\\
-            & this will be ran in pre-amble: \texttt{require('pl.text').format\_operator()}\\
-                & \hspace*{-4em}\url{https://stevedonovan.github.io/Penlight/api/libraries/pl.text.html}\\\\
+            & this will be ran in pre-amble: \texttt{require('pl.stringx').format\_operator()}\\
+      & \mbox{\url{https://lunarmodules.github.io/Penlight/libraries/pl.stringx.html#format_operator}}
+    \\
     \texttt{func} & allows placehold expressions eg. \texttt{\_1+1} to be used \\
                 & this will be ran in pre-amble: \texttt{penlight.utils.import('pl.func')}\\
-                & \hspace*{-4em}\url{https://stevedonovan.github.io/Penlight/api/libraries/pl.func.html}\\\\
-    \texttt{extras} & adds some additional functions. Adds functions to some Penlight sub-modules. The following Lua globals will be defined:
-                \texttt{close\_bkt\_cnt,
-                    add\_bkt\_cnt,
-                    reset\_bkt\_cnt,
-                    \_NumBkts,
-                \_xTrue,
-                \_xFalse,
-                \_xNoValue,
-                \_\_SKIP\_TEX\_\_,
-                hasval,
-                mod, mod2,
-                }\\
+    & \url{https://lunarmodules.github.io/Penlight/libraries/pl.func}\\
+    \texttt{\llap{extras}noglobals} & does the above three (\cmd{func,stringx,format}); adds some additional functions to \cmd{penlight} module; and adds the \cmd{pl.tex} sub-module.\\
+    \texttt{extras} & does the above \texttt{extrasnoglobals} but makes many of the functions global variables.
     \end{tabular}
-    \\\\
 
-    If you want to use Penlight (and extras) with the \texttt{texlua} intrepreter (no document made, only for Lua files, useful for testing),
-    you can access it by the following:
-     \begin{verbatim}
-    package.path = package.path .. ';'..'path/to/texmf/tex/latex/penlight/?.lua'
-    penlight = require('penlight')
-    __SKIP_TEX__ = true  --only required if you want to use
-                         --penlightextras without a LaTeX run
-    require('penlightextras')
-    \end{verbatim}
 
 
 
+\subsection*{Extras}
 
+If \cmd{extras} is used, the following Lua globals will be defined:\\
 
+\subsubsection*{Misc stuff}
+\llcmd{__SKIP_TEX__} If using package with \cmd{texlua}, set this global before loading \cmd{penlight}\\
+\llcmd{__PL_NO_}\cmd{GLOBALS__} If using package with \cmd{texlua} and you don't want to set some globals (described in next sections), set this global before to \cmd{true} loading \cmd{penlight}\\
+\llcmd{hasval(x)} Python-like boolean testing\\
+\llcmd{COMP'xyz'()} Python-like comprehensions:\\\url{https://lunarmodules.github.io/Penlight/libraries/pl.comprehension.html}\\
+\llcmd{math.mod(n,d)}, \cmd{math.mod2(n)} math modulous\\
+\llcmd{string.}\cmd{totable(s)} string a table of characters\\
+\llcmd{kpairs(t), }\cmd{npairs(t)} iterate over keys only, or include nil value from table ipairs\\
+
+\llcmd{pl.utils.}\cmd{filterfiles}\cmd{(dir,filt,rec)} Get files from dir and apply glob-like filters. Set rec to \cmd{true} to include sub directories\\
+
+\pagebreak
+
+\subsubsection*{\cmd{pl.tex.} module is added}
+\llcmd{add_bkt}\cmd{_cnt(n), }\cmd{close_bkt_cnt(n), reset_bkt_cnt} functions to keep track of adding curly brackets as strings. \cmd{add} will return \cmd{n} (default 1) \{'s and increment a counter. \cmd{close} will return \cmd{n} \}'s (default will close all brackets) and decrement.\\
+\llcmd{_NumBkts} internal integer for tracking the number of brackets\\
+\llcmd{opencmd(cs)} prints \cmd{\cs}\{ and adds to the bracket counters.\\
+\\
+\llcmd{_xNoValue,}\cmd{_xTrue,_xFalse}: \cmd{xparse} equivalents for commands\\
+\\
+\llcmd{prt(x),prtn(x)} print without or with a newline at end. Tries to help with special characters or numbers printing.\\
+\llcmd{prtl(l),prtt(t)} print a literal string, or table\\
+\llcmd{wrt(x), wrtn(x)} write to log\\
+\llcmd{help_wrt}\cmd{(s1, s2)} pretty-print something to console. S2 is a flag to help you find.\\
+\llcmd{prt_array2d(tt)} pretty print a 2d array\\
+\\
+\llcmd{pkgwarn}\cmd{(pkg, msg1, msg2)} throw a package warning\\
+\llcmd{pkgerror}\cmd{(pkg, msg1, msg2, stop)} throw a package error. If stop is true, immediately ceases compile.\\
+\\
+\llcmd{defcmd}\cmd{(cs, val)} like \cmd{\gdef}\\
+\llcmd{newcmd}\cmd{(cs, val)} like \cmd{\newcommand}\\
+\llcmd{renewcmd}\cmd{(cs, val)} like \cmd{\renewcommand}\\
+\llcmd{prvcmd}\cmd{(cs, val)} like \cmd{\providecommand}\\
+\llcmd{deccmd}\cmd{(cs, dft, overwrite)} declare a command. If \cmd{dft} (default) is \cmd{nil}, \cmd{cs} is set
+to a package warning saying \cmd{'cs' was declared and used in document, but never set}. If \cmd{overwrite}
+is true, it will overwrite an existing command (using \cmd{defcmd}), otherwise, it will throw error like \cmd{newcmd}.\\
+
+
+
+\subsubsection*{global extras}
+If \cmd{extras} is used and NOT \cmd{extrasnoglobals}, then some globals are set.\\
+All \cmd{pl.tex} modules are added.\\
+\cmd{hasval}, \cmd{COMP}, \cmd{kpairs}, \cmd{npairs} are globals.\\
+\cmd{pl.tablex} functions are added to the \cmd{table} table.\\
+
     \section*{}
     Disclaimer: I am not the author of the Lua Penlight library.
     Penlight is Copyright \textcopyright  2009-2016 Steve Donovan, David Manura.

Modified: trunk/Master/texmf-dist/tex/luatex/penlight/penlight.lua
===================================================================
--- trunk/Master/texmf-dist/tex/luatex/penlight/penlight.lua	2022-02-27 21:33:55 UTC (rev 62253)
+++ trunk/Master/texmf-dist/tex/luatex/penlight/penlight.lua	2022-02-27 21:34:10 UTC (rev 62254)
@@ -21,7 +21,7 @@
 --
 -- This implements some useful things on [LOM](http://matthewwild.co.uk/projects/luaexpat/lom.html) documents, such as returned by `lxp.lom.parse`.
 -- In particular, it can convert LOM back into XML text, with optional pretty-printing control.
--- It is s based on stanza.lua from [Prosody](http://hg.prosody.im/trunk/file/4621c92d2368/util/stanza.lua)
+-- It is based on stanza.lua from [Prosody](http://hg.prosody.im/trunk/file/4621c92d2368/util/stanza.lua)
 --
 --     > d = xml.parse "<nodes><node id='1'>alice</node></nodes>"
 --     > = d
@@ -49,493 +49,880 @@
 -- @module pl.xml
 
 local utils = require 'pl.utils'
-local split         =   utils.split;
-local t_insert      =  table.insert;
-local t_concat      =  table.concat;
-local t_remove      =  table.remove;
-local s_match       =  string.match;
-local tostring      =      tostring;
-local setmetatable  =  setmetatable;
-local getmetatable  =  getmetatable;
-local pairs         =         pairs;
-local ipairs        =        ipairs;
-local type          =          type;
-local next          =          next;
-local print         =         print;
-local unpack        =  utils.unpack;
-local s_gsub        =   string.gsub;
-local s_find        =   string.find;
-local pcall,require,io     =   pcall,require,io
+local split         =   utils.split
+local t_insert      =  table.insert
+local t_concat      =  table.concat
+local t_remove      =  table.remove
+local s_match       =  string.match
+local tostring      =      tostring
+local setmetatable  =  setmetatable
+local getmetatable  =  getmetatable
+local pairs         =         pairs
+local ipairs        =        ipairs
+local type          =          type
+local next          =          next
+local print         =         print
+local unpack        =  utils.unpack
+local s_gsub        =   string.gsub
+local s_sub         =    string.sub
+local s_find        =   string.find
+local pcall         =         pcall
+local require       =       require
 
+
+utils.raise_deprecation {
+  source = "Penlight " .. utils._VERSION,
+  message = "the contents of module 'pl.xml' has been deprecated, please use a more specialized library instead",
+  version_removed = "2.0.0",
+  deprecated_after = "1.11.0",
+  no_trace = true,
+}
+
+
+
 local _M = {}
 local Doc = { __type = "doc" };
 Doc.__index = Doc;
 
+
+local function is_text(s) return type(s) == 'string' end
+local function is_tag(d) return type(d) == 'table' and is_text(d.tag) end
+
+
+
 --- create a new document node.
--- @param tag the tag name
--- @param attr optional attributes (table of name-value pairs)
+-- @tparam string tag the tag name
+-- @tparam[opt={}] table attr attributes (table of name-value pairs)
+-- @return the Node object
+-- @see xml.elem
+-- @usage
+-- local doc = xml.new("main", { hello = "world", answer = "42" })
+-- print(doc)  -->  <main hello='world' answer='42'/>
 function _M.new(tag, attr)
-    local doc = { tag = tag, attr = attr or {}, last_add = {}};
-    return setmetatable(doc, Doc);
+  if type(tag) ~= "string" then
+    error("expected 'tag' to be a string value, got: " .. type(tag), 2)
+  end
+  attr = attr or {}
+  if type(attr) ~= "table" then
+    error("expected 'attr' to be a table value, got: " .. type(attr), 2)
+  end
+
+  local doc = { tag = tag, attr = attr, last_add = {}};
+  return setmetatable(doc, Doc);
 end
 
---- parse an XML document.  By default, this uses lxp.lom.parse, but
--- falls back to basic_parse, or if use_basic is true
--- @param text_or_file  file or string representation
+
+--- parse an XML document. By default, this uses lxp.lom.parse, but
+-- falls back to basic_parse, or if `use_basic` is truthy
+-- @param text_or_filename  file or string representation
 -- @param is_file whether text_or_file is a file name or not
 -- @param use_basic do a basic parse
 -- @return a parsed LOM document with the document metatatables set
 -- @return nil, error the error can either be a file error or a parse error
-function _M.parse(text_or_file, is_file, use_basic)
-    local parser,status,lom
-    if use_basic then parser = _M.basic_parse
+function _M.parse(text_or_filename, is_file, use_basic)
+  local parser,status,lom
+  if use_basic then
+    parser = _M.basic_parse
+  else
+    status,lom = pcall(require,'lxp.lom')
+    if not status then
+      parser = _M.basic_parse
     else
-        status,lom = pcall(require,'lxp.lom')
-        if not status then parser = _M.basic_parse else parser = lom.parse end
+      parser = lom.parse
     end
-    if is_file then
-        local f,err = io.open(text_or_file)
-        if not f then return nil,err end
-        text_or_file = f:read '*a'
-        f:close()
+  end
+
+  if is_file then
+    local text_or_filename, err = utils.readfile(text_or_filename)
+    if not text_or_filename then
+      return nil, err
     end
-    local doc,err = parser(text_or_file)
-    if not doc then return nil,err end
-    if lom then
-        _M.walk(doc,false,function(_,d)
-            setmetatable(d,Doc)
-        end)
+  end
+
+  local doc, err = parser(text_or_filename)
+  if not doc then
+    return nil, err
+  end
+
+  if lom then
+    _M.walk(doc, false, function(_, d)
+      setmetatable(d, Doc)
+    end)
+  end
+  return doc
+end
+
+
+--- Create a Node with a set of children (text or Nodes) and attributes.
+-- @tparam string tag a tag name
+-- @tparam table|string items either a single child (text or Node), or a table where the hash
+-- part is the attributes and the list part is the children (text or Nodes).
+-- @return the new Node
+-- @see xml.new
+-- @see xml.tags
+-- @usage
+-- local doc = xml.elem("top", "hello world")                -- <top>hello world</top>
+-- local doc = xml.elem("main", xml.new("child"))            -- <main><child/></main>
+-- local doc = xml.elem("main", { "this ", "is ", "nice" })  -- <main>this is nice</main>
+-- local doc = xml.elem("main", { xml.new "this",
+--                                xml.new "is",
+--                                xml.new "nice" })          -- <main><this/><is/><nice/></main>
+-- local doc = xml.elem("main", { hello = "world" })         -- <main hello='world'/>
+-- local doc = xml.elem("main", {
+--   "prefix",
+--   xml.elem("child", { "this ", "is ", "nice"}),
+--   "postfix",
+--   attrib = "value"
+-- })   -- <main attrib='value'>prefix<child>this is nice</child>postfix</main>"
+function _M.elem(tag, items)
+  local s = _M.new(tag)
+  if is_text(items) then items = {items} end
+  if is_tag(items) then
+    t_insert(s,items)
+  elseif type(items) == 'table' then
+    for k,v in pairs(items) do
+      if is_text(k) then
+        s.attr[k] = v
+        t_insert(s.attr,k)
+      else
+        s[k] = v
+      end
     end
-    return doc
+  end
+  return s
 end
 
----- convenient function to add a document node, This updates the last inserted position.
--- @param tag a tag name
--- @param attrs optional set of attributes (name-string pairs)
+
+--- given a list of names, return a number of element constructors.
+-- If passing a comma-separated string, then whitespace surrounding the values
+-- will be stripped.
+--
+-- The returned constructor functions are a shortcut to `xml.elem` where you
+-- no longer provide the tag-name, but only the `items` table.
+-- @tparam string|table list a list of names, or a comma-separated string.
+-- @return (multiple) constructor functions; `function(items)`. For the `items`
+-- parameter see `xml.elem`.
+-- @see xml.elem
+-- @usage
+-- local new_parent, new_child = xml.tags 'mom, kid'
+-- doc = new_parent {new_child 'Bob', new_child 'Annie'}
+-- -- <mom><kid>Bob</kid><kid>Annie</kid></mom>
+function _M.tags(list)
+  local ctors = {}
+  if is_text(list) then
+    list = split(list:match("^%s*(.-)%s*$"),'%s*,%s*')
+  end
+  for i,tag in ipairs(list) do
+    local function ctor(items)
+      return _M.elem(tag,items)
+    end
+    ctors[i] = ctor
+  end
+  return unpack(ctors)
+end
+
+
+--- Adds a document Node, at current position.
+-- This updates the last inserted position to the new Node.
+-- @tparam string tag the tag name
+-- @tparam[opt={}] table attrs attributes (table of name-value pairs)
+-- @return the current node (`self`)
+-- @usage
+-- local doc = xml.new("main")
+-- doc:addtag("penlight", { hello = "world"})
+-- doc:addtag("expat")  -- added to 'penlight' since position moved
+-- print(doc)  -->  <main><penlight hello='world'><expat/></penlight></main>
 function Doc:addtag(tag, attrs)
-    local s = _M.new(tag, attrs);
-    (self.last_add[#self.last_add] or self):add_direct_child(s);
-    t_insert(self.last_add, s);
-    return self;
+  local s = _M.new(tag, attrs)
+  self:add_child(s)
+  t_insert(self.last_add, s)
+  return self
 end
 
---- convenient function to add a text node.  This updates the last inserted position.
--- @param text a string
+
+--- Adds a text node, at current position.
+-- @tparam string text a string
+-- @return the current node (`self`)
+-- @usage
+-- local doc = xml.new("main")
+-- doc:text("penlight")
+-- doc:text("expat")
+-- print(doc)  -->  <main><penlightexpat</main>
 function Doc:text(text)
-    (self.last_add[#self.last_add] or self):add_direct_child(text);
-    return self;
+  self:add_child(text)
+  return self
 end
 
----- go up one level in a document
+
+--- Moves current position up one level.
+-- @return the current node (`self`)
 function Doc:up()
-    t_remove(self.last_add);
-    return self;
+  t_remove(self.last_add)
+  return self
 end
 
+
+--- Resets current position to top level.
+-- Resets to the `self` node.
+-- @return the current node (`self`)
 function Doc:reset()
-    local last_add = self.last_add;
-    for i = 1,#last_add do
-        last_add[i] = nil;
-    end
-    return self;
+  local last_add = self.last_add
+  for i = 1,#last_add do
+    last_add[i] = nil
+  end
+  return self
 end
 
---- append a child to a document directly.
+
+--- Append a child to the currrent Node (ignoring current position).
 -- @param child a child node (either text or a document)
+-- @return the current node (`self`)
+-- @usage
+-- local doc = xml.new("main")
+-- doc:add_direct_child("dog")
+-- doc:add_direct_child(xml.new("child"))
+-- doc:add_direct_child("cat")
+-- print(doc)  -->  <main>dog<child/>cat</main>
 function Doc:add_direct_child(child)
-    t_insert(self, child);
+  t_insert(self, child)
+  return self
 end
 
---- append a child to a document at the last element added
+
+--- Append a child at the current position (without changing position).
 -- @param child a child node (either text or a document)
+-- @return the current node (`self`)
+-- @usage
+-- local doc = xml.new("main")
+-- doc:addtag("one")
+-- doc:add_child(xml.new("item1"))
+-- doc:add_child(xml.new("item2"))
+-- doc:add_child(xml.new("item3"))
+-- print(doc)  -->  <main><one><item1/><item2/><item3/></one></main>
 function Doc:add_child(child)
-    (self.last_add[#self.last_add] or self):add_direct_child(child);
-    return self;
+  (self.last_add[#self.last_add] or self):add_direct_child(child)
+  return self
 end
 
+
 --accessing attributes: useful not to have to expose implementation (attr)
 --but also can allow attr to be nil in any future optimizations
 
---- set attributes of a document node.
--- @param t a table containing attribute/value pairs
-function Doc:set_attribs (t)
-    for k,v in pairs(t) do
-        self.attr[k] = v
-    end
+
+--- Set attributes of a document node.
+-- Will add/overwite values, but will not remove existing ones.
+-- Operates on the Node itself, will not take position into account.
+-- @tparam table t a table containing attribute/value pairs
+-- @return the current node (`self`)
+function Doc:set_attribs(t)
+  -- TODO: keep array part in sync
+  for k,v in pairs(t) do
+    self.attr[k] = v
+  end
+  return self
 end
 
---- set a single attribute of a document node.
+
+--- Set a single attribute of a document node.
+-- Operates on the Node itself, will not take position into account.
 -- @param a attribute
--- @param v its value
+-- @param v its value, pass in `nil` to delete the attribute
+-- @return the current node (`self`)
 function Doc:set_attrib(a,v)
-    self.attr[a] = v
+  -- TODO: keep array part in sync
+  self.attr[a] = v
+  return self
 end
 
---- access the attributes of a document node.
+
+--- Gets the attributes of a document node.
+-- Operates on the Node itself, will not take position into account.
+-- @return table with attributes (attribute/value pairs)
 function Doc:get_attribs()
-    return self.attr
+  return self.attr
 end
 
-local function is_text(s) return type(s) == 'string' end
 
---- function to create an element with a given tag name and a set of children.
--- @param tag a tag name
--- @param items either text or a table where the hash part is the attributes and the list part is the children.
-function _M.elem(tag,items)
-    local s = _M.new(tag)
-    if is_text(items) then items = {items} end
-    if _M.is_tag(items) then
-       t_insert(s,items)
-    elseif type(items) == 'table' then
-       for k,v in pairs(items) do
-           if is_text(k) then
-               s.attr[k] = v
-               t_insert(s.attr,k)
-           else
-               s[k] = v
-           end
-       end
-    end
-    return s
-end
 
---- given a list of names, return a number of element constructors.
--- @param list  a list of names, or a comma-separated string.
--- @usage local parent,children = doc.tags 'parent,children' <br>
---  doc = parent {child 'one', child 'two'}
-function _M.tags(list)
-    local ctors = {}
-    if is_text(list) then list = split(list,'%s*,%s*') end
-    for _,tag in ipairs(list) do
-        local ctor = function(items) return _M.elem(tag,items) end
-        t_insert(ctors,ctor)
-    end
-    return unpack(ctors)
-end
+local template_cache do
+  local templ_cache = {}
 
-local templ_cache = {}
+  -- @param templ a template, a string being valid xml to be parsed, or a Node object
+  function template_cache(templ)
+    if is_text(templ) then
+      if templ_cache[templ] then
+        -- cache hit
+        return templ_cache[templ]
 
-local function template_cache (templ)
-    if is_text(templ) then
-        if templ_cache[templ] then
-            templ = templ_cache[templ]
-        else
-            local str,err = templ
-            templ,err = _M.parse(str,false,true)
-            if not templ then return nil,err end
-            templ_cache[str] = templ
+      else
+        -- parse and cache
+        local ptempl, err = _M.parse(templ,false,true)
+        if not ptempl then
+          return nil, err
         end
-    elseif not _M.is_tag(templ) then
-        return nil, "template is not a document"
+        templ_cache[templ] = ptempl
+        return ptempl
+      end
     end
-    return templ
+
+    if is_tag(templ) then
+      return templ
+    end
+
+    return nil, "template is not a document"
+  end
 end
 
-local function is_data(data)
+
+do
+  local function is_data(data)
     return #data == 0 or type(data[1]) ~= 'table'
-end
+  end
 
-local function prepare_data(data)
+
+  local function prepare_data(data)
     -- a hack for ensuring that $1 maps to first element of data, etc.
     -- Either this or could change the gsub call just below.
     for i,v in ipairs(data) do
-        data[tostring(i)] = v
+      data[tostring(i)] = v
     end
-end
+  end
 
---- create a substituted copy of a document,
--- @param templ  may be a document or a string representation which will be parsed and cached
--- @param data  a table of name-value pairs or a list of such tables
--- @return an XML document
-function Doc.subst(templ, data)
-    local err
-    if type(data) ~= 'table' or not next(data) then return nil, "data must be a non-empty table" end
+  --- create a substituted copy of a document,
+  -- @param template may be a document or a string representation which will be parsed and cached
+  -- @param data a table of name-value pairs or a list of such tables
+  -- @return an XML document
+  function Doc.subst(template, data)
+    if type(data) ~= 'table' or not next(data) then
+      return nil, "data must be a non-empty table"
+    end
+
     if is_data(data) then
-        prepare_data(data)
+      prepare_data(data)
     end
-    templ,err = template_cache(templ)
-    if err then return nil, err end
+
+    local templ, err = template_cache(template)
+    if err then
+      return nil, err
+    end
+
     local function _subst(item)
-        return _M.clone(templ,function(s)
-            return s:gsub('%$(%w+)',item)
-        end)
+      return _M.clone(templ, function(s)
+        return s:gsub('%$(%w+)', item)
+      end)
     end
-    if is_data(data) then return _subst(data) end
+
+    if is_data(data) then
+      return _subst(data)
+    end
+
     local list = {}
-    for _,item in ipairs(data) do
-        prepare_data(item)
-        t_insert(list,_subst(item))
+    for _, item in ipairs(data) do
+      prepare_data(item)
+      t_insert(list, _subst(item))
     end
+
     if data.tag then
-        list = _M.elem(data.tag,list)
+      list = _M.elem(data.tag,list)
     end
     return list
+  end
 end
 
 
---- get the first child with a given tag name.
+--- Return the first child with a given tag name (non-recursive).
 -- @param tag the tag name
+-- @return the child Node found or `nil` if not found
 function Doc:child_with_name(tag)
-    for _, child in ipairs(self) do
-        if child.tag == tag then return child; end
+  for _, child in ipairs(self) do
+    if child.tag == tag then
+      return child
     end
+  end
 end
 
-local _children_with_name
-function _children_with_name(self,tag,list,recurse)
-    for _, child in ipairs(self) do if type(child) == 'table' then
-        if child.tag == tag then t_insert(list,child) end
-        if recurse then _children_with_name(child,tag,list,recurse) end
-    end end
-end
 
---- get all elements in a document that have a given tag.
--- @param tag a tag name
--- @param dont_recurse optionally only return the immediate children with this tag name
--- @return a list of elements
-function Doc:get_elements_with_name(tag,dont_recurse)
+do
+  -- @param self document node to traverse
+  -- @param tag tag-name to look for
+  -- @param list array table to add the matching ones to
+  -- @param recurse if truthy, recursivly search the node
+  local function _children_with_name(self, tag, list, recurse)
+    -- TODO: protect against recursion
+    for _, child in ipairs(self) do
+      if type(child) == 'table' then
+        if child.tag == tag then
+          t_insert(list, child)
+        end
+        if recurse then
+          _children_with_name(child, tag, list, recurse)
+        end
+      end
+    end
+  end
+
+  --- Returns all elements in a document that have a given tag.
+  -- @tparam string tag a tag name
+  -- @tparam[opt=false] boolean dont_recurse optionally only return the immediate children with this tag name
+  -- @return a list of elements found, list will be empty if none was found.
+  function Doc:get_elements_with_name(tag, dont_recurse)
     local res = {}
-    _children_with_name(self,tag,res,not dont_recurse)
+    _children_with_name(self, tag, res, not dont_recurse)
     return res
+  end
 end
 
--- iterate over all children of a document node, including text nodes.
+
+
+--- Iterator over all children of a document node, including text nodes.
+-- This function is not recursive, so returns only direct child nodes.
+-- @return iterator that returns a single Node per iteration.
 function Doc:children()
-    local i = 0;
-    return function (a)
-            i = i + 1
-            return a[i];
-    end, self, i;
+  local i = 0;
+  return function (a)
+    i = i + 1
+    return a[i];
+  end, self, i;
 end
 
--- return the first child element of a node, if it exists.
+
+--- Return the first child element of a node, if it exists.
+-- This will skip text nodes.
+-- @return first child Node or `nil` if there is none.
 function Doc:first_childtag()
-    if #self == 0 then return end
-    for _,t in ipairs(self) do
-        if type(t) == 'table' then return t end
+  if #self == 0 then
+    return
+  end
+  for _, t in ipairs(self) do
+    if is_tag(t) then
+      return t
     end
+  end
 end
 
+
+--- Iterator that matches tag names, and a namespace (non-recursive).
+-- @tparam[opt=nil] string tag tag names to return. Returns all tags if not provided.
+-- @tparam[opt=nil] string xmlns the namespace value ('xmlns' attribute) to return. If not
+-- provided will match all namespaces.
+-- @return iterator that returns a single Node per iteration.
 function Doc:matching_tags(tag, xmlns)
-    xmlns = xmlns or self.attr.xmlns;
-    local tags = self;
-    local start_i, max_i, v = 1, #tags;
-    return function ()
-            for i=start_i,max_i do
-                v = tags[i];
-                if (not tag or v.tag == tag)
-                and (not xmlns or xmlns == v.attr.xmlns) then
-                    start_i = i+1;
-                    return v;
-                end
-            end
-        end, tags, start_i;
+  -- TODO: this doesn't make sense??? namespaces are not "xmnls", as matched below
+  -- but "xmlns:name"... so should be a string-prefix match if anything...
+  xmlns = xmlns or self.attr.xmlns;
+  local tags = self
+  local next_i = 1
+  local max_i = #tags
+  local node
+  return function ()
+      for i = next_i, max_i do
+        node = tags[i];
+        if (not tag or node.tag == tag) and
+           (not xmlns or xmlns == node.attr.xmlns) then
+          next_i = i + 1
+          return node
+        end
+      end
+    end, tags, next_i
 end
 
---- iterate over all child elements of a document node.
+
+--- Iterator over all child tags of a document node. This will skip over
+-- text nodes.
+-- @return iterator that returns a single Node per iteration.
 function Doc:childtags()
-    local i = 0;
-    return function (a)
-        local v
-            repeat
-                i = i + 1
-                v = self[i]
-                if v and type(v) == 'table' then return v; end
-            until not v
-        end, self[1], i;
+  local i = 0;
+  return function (a)
+    local v
+      repeat
+        i = i + 1
+        v = self[i]
+        if v and type(v) == 'table' then
+          return v
+        end
+      until not v
+    end, self[1], i;
 end
 
---- visit child element  of a node and call a function, possibility modifying the document.
--- @param callback  a function passed the node (text or element). If it returns nil, that node will be removed.
--- If it returns a value, that will replace the current node.
+
+--- Visit child Nodes of a node and call a function, possibly modifying the document.
+-- Text elements will be skipped.
+-- This is not recursive, so only direct children will be passed.
+-- @tparam function callback a function with signature `function(node)`, passed the node.
+-- The element will be updated with the returned value, or deleted if it returns `nil`.
 function Doc:maptags(callback)
-    local is_tag = _M.is_tag
-    local i = 1;
-    while i <= #self do
-        if is_tag(self[i]) then
-            local ret = callback(self[i]);
-            if ret == nil then
-                t_remove(self, i);
-            else
-                self[i] = ret;
-                i = i + 1;
-            end
-        end
+  local i = 1;
+
+  while i <= #self do
+    if is_tag(self[i]) then
+      local ret = callback(self[i]);
+      if ret == nil then
+        -- remove it
+        t_remove(self, i);
+
+      else
+        -- update it
+        self[i] = ret;
+        i = i + 1;
+      end
+    else
+      i = i + 1
     end
-    return self;
+  end
+
+  return self;
 end
 
-local xml_escape
+
 do
-    local escape_table = { ["'"] = "'", ["\""] = """, ["<"] = "<", [">"] = ">", ["&"] = "&" };
-    function xml_escape(str) return (s_gsub(str, "['&<>\"]", escape_table)); end
-    _M.xml_escape = xml_escape;
+  local escape_table = {
+    ["'"] = "'",
+    ['"'] = """,
+    ["<"] = "<",
+    [">"] = ">",
+    ["&"] = "&",
+  }
+
+  --- Escapes a string for safe use in xml.
+  -- Handles quotes(single+double), less-than, greater-than, and ampersand.
+  -- @tparam string str string value to escape
+  -- @return escaped string
+  -- @usage
+  -- local esc = xml.xml_escape([["'<>&]])  --> ""'<>&"
+  function _M.xml_escape(str)
+    return (s_gsub(str, "['&<>\"]", escape_table))
+  end
 end
+local xml_escape = _M.xml_escape
 
+do
+  local escape_table = {
+    quot = '"',
+    apos = "'",
+    lt = "<",
+    gt = ">",
+    amp = "&",
+  }
+
+  --- Unescapes a string from xml.
+  -- Handles quotes(single+double), less-than, greater-than, and ampersand.
+  -- @tparam string str string value to unescape
+  -- @return unescaped string
+  -- @usage
+  -- local unesc = xml.xml_escape(""'<>&")  --> [["'<>&]]
+  function _M.xml_unescape(str)
+    return (str:gsub( "&(%a+);", escape_table))
+  end
+end
+local xml_unescape = _M.xml_unescape
+
 -- pretty printing
 -- if indent, then put each new tag on its own line
 -- if attr_indent, put each new attribute on its own line
-local function _dostring(t, buf, self, xml_escape, parentns, idn, indent, attr_indent)
-    local nsid = 0;
-    local tag = t.tag
-    local lf,alf = ""," "
-    if indent then lf = '\n'..idn end
-    if attr_indent then alf = '\n'..idn..attr_indent end
-    t_insert(buf, lf.."<"..tag);
-    local function write_attr(k,v)
-        if s_find(k, "\1", 1, true) then
-            local ns, attrk = s_match(k, "^([^\1]*)\1?(.*)$");
-            nsid = nsid + 1;
-            t_insert(buf, " xmlns:ns"..nsid.."='"..xml_escape(ns).."' ".."ns"..nsid..":"..attrk.."='"..xml_escape(v).."'");
-        elseif not(k == "xmlns" and v == parentns) then
-            t_insert(buf, alf..k.."='"..xml_escape(v).."'");
-        end
+local function _dostring(t, buf, parentns, block_indent, tag_indent, attr_indent)
+  local nsid = 0
+  local tag = t.tag
+
+  local lf = ""
+  if tag_indent then
+    lf = '\n'..block_indent
+  end
+
+  local alf = " "
+  if attr_indent then
+    alf = '\n'..block_indent..attr_indent
+  end
+
+  t_insert(buf, lf.."<"..tag)
+
+  local function write_attr(k,v)
+    if s_find(k, "\1", 1, true) then
+      nsid = nsid + 1
+      local ns, attrk = s_match(k, "^([^\1]*)\1?(.*)$")
+      t_insert(buf, " xmlns:ns"..nsid.."='"..xml_escape(ns).."' ".."ns"..nsid..":"..attrk.."='"..xml_escape(v).."'")
+
+    elseif not (k == "xmlns" and v == parentns) then
+      t_insert(buf, alf..k.."='"..xml_escape(v).."'");
     end
-    -- it's useful for testing to have predictable attribute ordering, if available
-    if #t.attr > 0 then
-        for _,k in ipairs(t.attr) do
-            write_attr(k,t.attr[k])
-        end
-    else
-        for k, v in pairs(t.attr) do
-            write_attr(k,v)
-        end
+  end
+
+  -- it's useful for testing to have predictable attribute ordering, if available
+  if #t.attr > 0 then
+    -- TODO: the key-value list is leading, what if they are not in-sync
+    for _,k in ipairs(t.attr) do
+      write_attr(k,t.attr[k])
     end
-    local len,has_children = #t;
-    if len == 0 then
-    local out = "/>"
-    if attr_indent then out = '\n'..idn..out end
-        t_insert(buf, out);
-    else
-        t_insert(buf, ">");
-        for n=1,len do
-            local child = t[n];
-            if child.tag then
-                self(child, buf, self, xml_escape, t.attr.xmlns,idn and idn..indent, indent, attr_indent );
-                has_children = true
-            else -- text element
-                t_insert(buf, xml_escape(child));
-            end
-        end
-        t_insert(buf, (has_children and lf or '').."</"..tag..">");
+  else
+    for k, v in pairs(t.attr) do
+      write_attr(k,v)
     end
+  end
+
+  local len = #t
+  local has_children
+
+  if len == 0 then
+    t_insert(buf, attr_indent and '\n'..block_indent.."/>" or "/>")
+
+  else
+    t_insert(buf, ">");
+
+    for n = 1, len do
+      local child = t[n]
+
+      if child.tag then
+        has_children = true
+        _dostring(child, buf, t.attr.xmlns, block_indent and block_indent..tag_indent, tag_indent, attr_indent)
+
+      else
+        -- text element
+        t_insert(buf, xml_escape(child))
+      end
+    end
+
+    t_insert(buf, (has_children and lf or '').."</"..tag..">");
+  end
 end
 
----- pretty-print an XML document
---- @param t an XML document
---- @param idn an initial indent (indents are all strings)
---- @param indent an indent for each level
---- @param attr_indent if given, indent each attribute pair and put on a separate line
---- @param xml force prefacing with default or custom <?xml...>
---- @return a string representation
-function _M.tostring(t,idn,indent, attr_indent, xml)
-    local buf = {};
-    if xml then
-        if type(xml) == "string" then
-            buf[1] = xml
-        else
-            buf[1] = "<?xml version='1.0'?>"
-        end
+--- Function to pretty-print an XML document.
+-- @param doc an XML document
+-- @tparam[opt] string|int b_ind an initial block-indent (required when `t_ind` is set)
+-- @tparam[opt] string|int t_ind an tag-indent for each level (required when `a_ind` is set)
+-- @tparam[opt] string|int a_ind if given, indent each attribute pair and put on a separate line
+-- @tparam[opt] string|bool xml_preface force prefacing with default or custom <?xml...>, if truthy then `<?xml version='1.0'?>` will be used as default.
+-- @return a string representation
+-- @see Doc:tostring
+function _M.tostring(doc, b_ind, t_ind, a_ind, xml_preface)
+  local buf = {}
+
+  if type(b_ind) == "number" then b_ind = (" "):rep(b_ind) end
+  if type(t_ind) == "number" then t_ind = (" "):rep(t_ind) end
+  if type(a_ind) == "number" then a_ind = (" "):rep(a_ind) end
+
+  if xml_preface then
+    if type(xml_preface) == "string" then
+      buf[1] = xml_preface
+    else
+      buf[1] = "<?xml version='1.0'?>"
     end
-    _dostring(t, buf, _dostring, xml_escape, nil,idn,indent, attr_indent);
-    return t_concat(buf);
+  end
+
+  _dostring(doc, buf, nil, b_ind, t_ind, a_ind, xml_preface)
+
+  return t_concat(buf)
 end
 
+
 Doc.__tostring = _M.tostring
 
---- get the full text value of an element
+
+--- Method to pretty-print an XML document.
+-- Invokes `xml.tostring`.
+-- @tparam[opt] string|int b_ind an initial indent (required when `t_ind` is set)
+-- @tparam[opt] string|int t_ind an indent for each level (required when `a_ind` is set)
+-- @tparam[opt] string|int a_ind if given, indent each attribute pair and put on a separate line
+-- @tparam[opt="<?xml version='1.0'?>"] string xml_preface force prefacing with default or custom <?xml...>
+-- @return a string representation
+-- @see xml.tostring
+function Doc:tostring(b_ind, t_ind, a_ind, xml_preface)
+  return _M.tostring(self, b_ind, t_ind, a_ind, xml_preface)
+end
+
+
+--- get the full text value of an element.
+-- @return a single string with all text elements concatenated
+-- @usage
+-- local doc = xml.new("main")
+-- doc:text("one")
+-- doc:add_child(xml.elem "two")
+-- doc:text("three")
+--
+-- local t = doc:get_text()    -->  "onethree"
 function Doc:get_text()
-    local res = {}
-    for i,el in ipairs(self) do
-        if is_text(el) then t_insert(res,el) end
-    end
-    return t_concat(res);
+  local res = {}
+  for i,el in ipairs(self) do
+    if is_text(el) then t_insert(res,el) end
+  end
+  return t_concat(res);
 end
 
---- make a copy of a document
--- @param doc the original document
--- @param strsubst an optional function for handling string copying which could do substitution, etc.
-function _M.clone(doc, strsubst)
-    local lookup_table = {};
-    local function _copy(object,kind,parent)
-        if type(object) ~= "table" then
-            if strsubst and is_text(object) then return strsubst(object,kind,parent)
-            else return object
-            end
-        elseif lookup_table[object] then
-            return lookup_table[object]
+
+do
+  local function _copy(object, kind, parent, strsubst, lookup_table)
+    if type(object) ~= "table" then
+      if strsubst and is_text(object) then
+        return strsubst(object, kind, parent)
+      else
+        return object
+      end
+    end
+
+    if lookup_table[object] then
+      error("recursion detected")
+    end
+    lookup_table[object] = true
+
+    local new_table = {}
+    lookup_table[object] = new_table
+
+    local tag = object.tag
+    new_table.tag = _copy(tag, '*TAG', parent, strsubst, lookup_table)
+
+    if object.attr then
+      local res = {}
+      for attr, value in pairs(object.attr) do
+        if type(attr) == "string" then
+          res[attr] = _copy(value, attr, object, strsubst, lookup_table)
         end
-        local new_table = {};
-        lookup_table[object] = new_table
-        local tag = object.tag
-        new_table.tag = _copy(tag,'*TAG',parent)
-        if object.attr then
-            local res = {}
-            for attr,value in pairs(object.attr) do
-                res[attr] = _copy(value,attr,object)
-            end
-            new_table.attr = res
-        end
-        for index = 1,#object do
-            local v = _copy(object[index],'*TEXT',object)
-            t_insert(new_table,v)
-        end
-        return setmetatable(new_table, getmetatable(object))
+      end
+      new_table.attr = res
     end
 
-    return _copy(doc)
+    for index = 1, #object do
+      local v = _copy(object[index], '*TEXT', object, strsubst, lookup_table)
+      t_insert(new_table,v)
+    end
+
+    return setmetatable(new_table, getmetatable(object))
+  end
+
+  --- Returns a copy of a document.
+  -- The `strsubst` parameter is a callback with signature `function(object, kind, parent)`.
+  --
+  -- Param `kind` has the following values, and parameters:
+  --
+  -- - `"*TAG"`: `object` is the tag-name, `parent` is the Node object. Returns the new tag name.
+  --
+  -- - `"*TEXT"`: `object` is the text-element, `parent` is the Node object. Returns the new text value.
+  --
+  -- - other strings not prefixed with `*`: `kind` is the attribute name, `object` is the
+  --   attribute value, `parent` is the Node object. Returns the new attribute value.
+  --
+  -- @tparam Node|string doc a Node object or string (text node)
+  -- @tparam[opt] function strsubst an optional function for handling string copying
+  -- which could do substitution, etc.
+  -- @return copy of the document
+  -- @see Doc:filter
+  function _M.clone(doc, strsubst)
+    return _copy(doc, nil, nil, strsubst, {})
+  end
 end
 
+
+--- Returns a copy of a document.
+-- This is the method version of `xml.clone`.
+-- @see xml.clone
+-- @name Doc:filter
+-- @tparam[opt] function strsubst an optional function for handling string copying
 Doc.filter = _M.clone -- also available as method
 
---- compare two documents.
--- @param t1 any value
--- @param t2 any value
-function _M.compare(t1,t2)
+do
+  local function _compare(t1, t2, recurse_check)
+
     local ty1 = type(t1)
     local ty2 = type(t2)
-    if ty1 ~= ty2 then return false, 'type mismatch' end
+
+    if ty1 ~= ty2 then
+      return false, 'type mismatch'
+    end
+
     if ty1 == 'string' then
-        return t1 == t2 and true or 'text '..t1..' ~= text '..t2
+      if t1 == t2 then
+        return true
+      else
+        return false, 'text '..t1..' ~= text '..t2
+      end
     end
-    if ty1 ~= 'table' or ty2 ~= 'table' then return false, 'not a document' end
-    if t1.tag ~= t2.tag then return false, 'tag  '..t1.tag..' ~= tag '..t2.tag end
-    if #t1 ~= #t2 then return false, 'size '..#t1..' ~= size '..#t2..' for tag '..t1.tag end
+
+    if ty1 ~= 'table' or ty2 ~= 'table' then
+      return false, 'not a document'
+    end
+
+    if recurse_check[t1] then
+      return false, "recursive document"
+    end
+    recurse_check[t1] = true
+
+    if t1.tag ~= t2.tag then
+      return false, 'tag  '..t1.tag..' ~= tag '..t2.tag
+    end
+
+    if #t1 ~= #t2 then
+      return false, 'size '..#t1..' ~= size '..#t2..' for tag '..t1.tag
+    end
+
     -- compare attributes
     for k,v in pairs(t1.attr) do
-        if t2.attr[k] ~= v then return false, 'mismatch attrib' end
+      local t2_value = t2.attr[k]
+      if type(k) == "string" then
+        if t2_value ~= v then return false, 'mismatch attrib' end
+      else
+        if t2_value ~= nil and t2_value ~= v then return false, "mismatch attrib order" end
+      end
     end
     for k,v in pairs(t2.attr) do
-        if t1.attr[k] ~= v then return false, 'mismatch attrib' end
+      local t1_value = t1.attr[k]
+      if type(k) == "string" then
+        if t1_value ~= v then return false, 'mismatch attrib' end
+      else
+        if t1_value ~= nil and t1_value ~= v then return false, "mismatch attrib order" end
+      end
     end
+
     -- compare children
-    for i = 1,#t1 do
-        local yes,err = _M.compare(t1[i],t2[i])
-        if not yes then return err end
+    for i = 1, #t1 do
+      local ok, err = _compare(t1[i], t2[i], recurse_check)
+      if not ok then
+        return ok, err
+      end
     end
     return true
+  end
+
+  --- Compare two documents or elements.
+  -- Equality is based on tag, child nodes (text and tags), attributes and order
+  -- of those (order only fails if both are given, and not equal).
+  -- @tparam Node|string t1 a Node object or string (text node)
+  -- @tparam Node|string t2 a Node object or string (text node)
+  -- @treturn boolean `true` when the Nodes are equal.
+  function _M.compare(t1,t2)
+    return _compare(t1, t2, {})
+  end
 end
 
+
 --- is this value a document element?
 -- @param d any value
-function _M.is_tag(d)
-    return type(d) == 'table' and is_text(d.tag)
-end
+-- @treturn boolean `true` if it is a `table` with property `tag` being a string value.
+-- @name is_tag
+_M.is_tag = is_tag
 
---- call the desired function recursively over the document.
--- @param doc the document
--- @param depth_first  visit child notes first, then the current node
--- @param operation a function which will receive the current tag name and current node.
-function _M.walk (doc, depth_first, operation)
-    if not depth_first then operation(doc.tag,doc) end
+
+do
+  local function _walk(doc, depth_first, operation, recurse_check)
+    if not depth_first then operation(doc.tag, doc) end
     for _,d in ipairs(doc) do
-        if _M.is_tag(d) then
-            _M.walk(d,depth_first,operation)
-        end
+      if is_tag(d) then
+        assert(not recurse_check[d], "recursion detected")
+        recurse_check[d] = true
+        _walk(d, depth_first, operation, recurse_check)
+      end
     end
-    if depth_first then operation(doc.tag,doc) end
+    if depth_first then operation(doc.tag, doc) end
+  end
+
+  --- Calls a function recursively over Nodes in the document.
+  -- Will only call on tags, it will skip text nodes.
+  -- The function signature for `operation` is `function(tag_name, Node)`.
+  -- @tparam Node|string doc a Node object or string (text node)
+  -- @tparam boolean depth_first visit child nodes first, then the current node
+  -- @tparam function operation a function which will receive the current tag name and current node.
+  function _M.walk(doc, depth_first, operation)
+    return _walk(doc, depth_first, operation, {})
+  end
 end
 
+
 local html_empty_elements = { --lists all HTML empty (void) elements
     br      = true,
     img     = true,
@@ -553,13 +940,10 @@
     embed = true,
 }
 
-local escapes = { quot = "\"", apos = "'", lt = "<", gt = ">", amp = "&" }
-local function unescape(str) return (str:gsub( "&(%a+);", escapes)); end
-
 --- Parse a well-formed HTML file as a string.
 -- Tags are case-insenstive, DOCTYPE is ignored, and empty elements can be .. empty.
 -- @param s the HTML
-function _M.parsehtml (s)
+function _M.parsehtml(s)
     return _M.basic_parse(s,false,true)
 end
 
@@ -567,9 +951,7 @@
 -- @param s the XML document to be parsed.
 -- @param all_text  if true, preserves all whitespace. Otherwise only text containing non-whitespace is included.
 -- @param html if true, uses relaxed HTML rules for parsing
-function _M.basic_parse(s,all_text,html)
-    local t_insert,t_remove = table.insert,table.remove
-    local s_find,s_sub = string.find,string.sub
+function _M.basic_parse(s, all_text, html)
     local stack = {}
     local top = {}
 
@@ -577,12 +959,12 @@
       local arg = {}
       s:gsub("([%w:%-_]+)%s*=%s*([\"'])(.-)%2", function (w, _, a)
         if html then w = w:lower() end
-        arg[w] = unescape(a)
+        arg[w] = xml_unescape(a)
       end)
       if html then
         s:gsub("([%w:%-_]+)%s*=%s*([^\"']+)%s*", function (w, a)
           w = w:lower()
-          arg[w] = unescape(a)
+          arg[w] = xml_unescape(a)
         end)
       end
       return arg
@@ -617,7 +999,7 @@
                 if html_empty_elements[label] then empty = "/" end
             end
             if all_text or not s_find(text, "^%s*$") then
-                t_insert(top, unescape(text))
+                t_insert(top, xml_unescape(text))
             end
             if empty == "/" then  -- empty element tag
                 t_insert(top, setmetatable({tag=label, attr=parseargs(xarg), empty=1},Doc))
@@ -640,7 +1022,7 @@
     end
     local text = s_sub(s, i)
     if all_text or  not s_find(text, "^%s*$") then
-        t_insert(stack[#stack], unescape(text))
+        t_insert(stack[#stack], xml_unescape(text))
     end
     if #stack > 1 then
         error("unclosed "..stack[#stack].tag)
@@ -649,145 +1031,151 @@
     return is_text(res[1]) and res[2] or res[1]
 end
 
-local function empty(attr) return not attr or not next(attr) end
-local function is_element(d) return type(d) == 'table' and d.tag ~= nil end
+do
+  local match do
 
--- returns the key,value pair from a table if it has exactly one entry
-local function has_one_element(t)
-    local key,value = next(t)
-    if next(t,key) ~= nil then return false end
-    return key,value
-end
+    local function empty(attr) return not attr or not next(attr) end
 
-local function append_capture(res,tbl)
-    if not empty(tbl) then -- no point in capturing empty tables...
-        local key
-        if tbl._ then  -- if $_ was set then it is meant as the top-level key for the captured table
-            key = tbl._
-            tbl._ = nil
-            if empty(tbl) then return end
+    local append_capture do
+      -- returns the key,value pair from a table if it has exactly one entry
+      local function has_one_element(t)
+          local key,value = next(t)
+          if next(t,key) ~= nil then return false end
+          return key,value
+      end
+
+      function append_capture(res,tbl)
+          if not empty(tbl) then -- no point in capturing empty tables...
+              local key
+              if tbl._ then  -- if $_ was set then it is meant as the top-level key for the captured table
+                  key = tbl._
+                  tbl._ = nil
+                  if empty(tbl) then return end
+              end
+              -- a table with only one pair {[0]=value} shall be reduced to that value
+              local numkey,val = has_one_element(tbl)
+              if numkey == 0 then tbl = val end
+              if key then
+                  res[key] = tbl
+              else -- otherwise, we append the captured table
+                  t_insert(res,tbl)
+              end
+          end
+      end
+    end
+
+    local function make_number(pat)
+        if pat:find '^%d+$' then -- $1 etc means use this as an array location
+            pat = tonumber(pat)
         end
-        -- a table with only one pair {[0]=value} shall be reduced to that value
-        local numkey,val = has_one_element(tbl)
-        if numkey == 0 then tbl = val end
-        if key then
-            res[key] = tbl
-        else -- otherwise, we append the captured table
-            t_insert(res,tbl)
-        end
+        return pat
     end
-end
 
-local function make_number(pat)
-    if pat:find '^%d+$' then -- $1 etc means use this as an array location
-        pat = tonumber(pat)
+    local function capture_attrib(res,pat,value)
+        pat = make_number(pat:sub(2))
+        res[pat] = value
+        return true
     end
-    return pat
-end
 
-local function capture_attrib(res,pat,value)
-    pat = make_number(pat:sub(2))
-    res[pat] = value
-    return true
-end
-
-local match
-function match(d,pat,res,keep_going)
-    local ret = true
-    if d == nil then d = '' end --return false end
-    -- attribute string matching is straight equality, except if the pattern is a $ capture,
-    -- which always succeeds.
-    if is_text(d) then
-        if not is_text(pat) then return false end
-        if _M.debug then print(d,pat) end
-        if pat:find '^%$' then
-            return capture_attrib(res,pat,d)
+    function match(d,pat,res,keep_going)
+        local ret = true
+        if d == nil then d = '' end --return false end
+        -- attribute string matching is straight equality, except if the pattern is a $ capture,
+        -- which always succeeds.
+        if is_text(d) then
+            if not is_text(pat) then return false end
+            if _M.debug then print(d,pat) end
+            if pat:find '^%$' then
+                return capture_attrib(res,pat,d)
+            else
+                return d == pat
+            end
         else
-            return d == pat
-        end
-    else
-    if _M.debug then print(d.tag,pat.tag) end
-        -- this is an element node. For a match to succeed, the attributes must
-        -- match as well.
-        -- a tagname in the pattern ending with '-' is a wildcard and matches like an attribute
-        local tagpat = pat.tag:match '^(.-)%-$'
-        if tagpat then
-            tagpat = make_number(tagpat)
-            res[tagpat] = d.tag
-        end
-        if d.tag == pat.tag or tagpat then
+        if _M.debug then print(d.tag,pat.tag) end
+            -- this is an element node. For a match to succeed, the attributes must
+            -- match as well.
+            -- a tagname in the pattern ending with '-' is a wildcard and matches like an attribute
+            local tagpat = pat.tag:match '^(.-)%-$'
+            if tagpat then
+                tagpat = make_number(tagpat)
+                res[tagpat] = d.tag
+            end
+            if d.tag == pat.tag or tagpat then
 
-            if not empty(pat.attr) then
-                if empty(d.attr) then ret =  false
-                else
-                    for prop,pval in pairs(pat.attr) do
-                        local dval = d.attr[prop]
-                        if not match(dval,pval,res) then ret = false;  break end
+                if not empty(pat.attr) then
+                    if empty(d.attr) then ret =  false
+                    else
+                        for prop,pval in pairs(pat.attr) do
+                            local dval = d.attr[prop]
+                            if not match(dval,pval,res) then ret = false;  break end
+                        end
                     end
                 end
+                -- the pattern may have child nodes. We match partially, so that {P1,P2} shall match {X,P1,X,X,P2,..}
+                if ret and #pat > 0 then
+                    local i,j = 1,1
+                    local function next_elem()
+                        j = j + 1  -- next child element of data
+                        if is_text(d[j]) then j = j + 1 end
+                        return j <= #d
+                    end
+                    repeat
+                        local p = pat[i]
+                        -- repeated {{<...>}} patterns  shall match one or more elements
+                        -- so e.g. {P+} will match {X,X,P,P,X,P,X,X,X}
+                        if is_tag(p) and p.repeated then
+                            local found
+                            repeat
+                                local tbl = {}
+                                ret = match(d[j],p,tbl,false)
+                                if ret then
+                                    found = false --true
+                                    append_capture(res,tbl)
+                                end
+                            until not next_elem() or (found and not ret)
+                            i = i + 1
+                        else
+                            ret = match(d[j],p,res,false)
+                            if ret then i = i + 1 end
+                        end
+                    until not next_elem() or i > #pat -- run out of elements or patterns to match
+                    -- if every element in our pattern matched ok, then it's been a successful match
+                    if i > #pat then return true end
+                end
+                if ret then return true end
+            else
+                ret = false
             end
-            -- the pattern may have child nodes. We match partially, so that {P1,P2} shall match {X,P1,X,X,P2,..}
-            if ret and #pat > 0 then
-                local i,j = 1,1
-                local function next_elem()
-                    j = j + 1  -- next child element of data
-                    if is_text(d[j]) then j = j + 1 end
-                    return j <= #d
+            -- keep going anyway - look at the children!
+            if keep_going then
+                for child in d:childtags() do
+                    ret = match(child,pat,res,keep_going)
+                    if ret then break end
                 end
-                repeat
-                    local p = pat[i]
-                    -- repeated {{<...>}} patterns  shall match one or more elements
-                    -- so e.g. {P+} will match {X,X,P,P,X,P,X,X,X}
-                    if is_element(p) and p.repeated then
-                        local found
-                        repeat
-                            local tbl = {}
-                            ret = match(d[j],p,tbl,false)
-                            if ret then
-                                found = false --true
-                                append_capture(res,tbl)
-                            end
-                        until not next_elem() or (found and not ret)
-                        i = i + 1
-                    else
-                        ret = match(d[j],p,res,false)
-                        if ret then i = i + 1 end
-                    end
-                until not next_elem() or i > #pat -- run out of elements or patterns to match
-                -- if every element in our pattern matched ok, then it's been a successful match
-                if i > #pat then return true end
             end
-            if ret then return true end
-        else
-            ret = false
         end
-        -- keep going anyway - look at the children!
-        if keep_going then
-            for child in d:childtags() do
-                ret = match(child,pat,res,keep_going)
-                if ret then break end
-            end
-        end
+        return ret
     end
-    return ret
-end
+  end
 
-function Doc:match(pat)
-    local err
-    pat,err = template_cache(pat)
-    if not pat then return nil, err end
-    _M.walk(pat,false,function(_,d)
-        if is_text(d[1]) and is_element(d[2]) and is_text(d[3]) and
-           d[1]:find '%s*{{' and d[3]:find '}}%s*' then
-           t_remove(d,1)
-           t_remove(d,2)
-           d[1].repeated = true
-        end
-    end)
+  --- does something...
+  function Doc:match(pat)
+      local err
+      pat,err = template_cache(pat)
+      if not pat then return nil, err end
+      _M.walk(pat,false,function(_,d)
+          if is_text(d[1]) and is_tag(d[2]) and is_text(d[3]) and
+            d[1]:find '%s*{{' and d[3]:find '}}%s*' then
+            t_remove(d,1)
+            t_remove(d,2)
+            d[1].repeated = true
+          end
+      end)
 
-    local res = {}
-    local ret = match(self,pat,res,true)
-    return res,ret
+      local res = {}
+      local ret = match(self,pat,res,true)
+      return res,ret
+  end
 end
 
 
@@ -1416,14 +1804,14 @@
         error('bad cell specifier: '..s)
     end
 
-    --- parse a spreadsheet range.
-    -- The range can be specified either as 'A1:B2' or 'R1C1:R2C2';
-    -- a special case is a single element (e.g 'A1' or 'R1C1')
+    --- parse a spreadsheet range or cell.
+    -- The range/cell can be specified either as 'A1:B2' or 'R1C1:R2C2' or for
+    -- single cells as 'A1' or 'R1C1'.
     -- @string s a range (case insensitive).
     -- @treturn int start row
     -- @treturn int start col
-    -- @treturn int end row
-    -- @treturn int end col
+    -- @treturn int end row (or `nil` if the range was a single cell)
+    -- @treturn int end col (or `nil` if the range was a single cell)
     function array2d.parse_range (s)
         assert_arg(1,s,'string')
         s = s:upper()
@@ -1439,15 +1827,11 @@
     end
 end
 
---- get a slice of a 2D array using spreadsheet range notation. @see parse_range
--- @array2d t a 2D array
--- @string rstr range expression
--- @return a slice
--- @see array2d.parse_range
--- @see array2d.slice
-function array2d.range (t,rstr)
-    assert_arg(1,t,'table')
-    return array2d.slice(t,array2d.parse_range(rstr))
+--- get a slice of a 2D array.
+-- Same as `slice`.
+-- @see slice
+function array2d.range (...)
+    return array2d.slice(...)
 end
 
 local default_range do
@@ -1465,12 +1849,16 @@
     -- Negative indices will be counted from the end, too low, or too high
     -- will be limited by the array sizes.
     -- @array2d t a 2D array
-    -- @int i1 start row (default 1)
-    -- @int j1 start col (default 1)
-    -- @int i2 end row   (default N)
-    -- @int j2 end col   (default M)
-    -- return i1, j1, i2, j2
+    -- @tparam[opt=1] int|string i1 start row or spreadsheet range passed to `parse_range`
+    -- @tparam[opt=1] int j1 start col
+    -- @tparam[opt=N] int i2 end row
+    -- @tparam[opt=M] int j2 end col
+    -- @see parse_range
+    -- @return i1, j1, i2, j2
     function array2d.default_range (t,i1,j1,i2,j2)
+        if (type(i1) == 'string') and not (j1 or i2 or j2) then
+            i1, j1, i2, j2 = array2d.parse_range(i1)
+        end
         local nr, nc = array2d.size(t)
         i1 = norm_value(i1 or 1, nr)
         j1 = norm_value(j1 or 1, nc)
@@ -1484,10 +1872,11 @@
 --- get a slice of a 2D array. Note that if the specified range has
 -- a 1D result, the rank of the result will be 1.
 -- @array2d t a 2D array
--- @int i1 start row (default 1)
--- @int j1 start col (default 1)
--- @int i2 end row   (default N)
--- @int j2 end col   (default M)
+-- @tparam[opt=1] int|string i1 start row or spreadsheet range passed to `parse_range`
+-- @tparam[opt=1] int j1 start col
+-- @tparam[opt=N] int i2 end row
+-- @tparam[opt=M] int j2 end col
+-- @see parse_range
 -- @return an array, 2D in general but 1D in special cases.
 function array2d.slice (t,i1,j1,i2,j2)
     assert_arg(1,t,'table')
@@ -1513,10 +1902,11 @@
 --- set a specified range of an array to a value.
 -- @array2d t a 2D array
 -- @param value the value (may be a function, called as `val(i,j)`)
--- @int i1 start row (default 1)
--- @int j1 start col (default 1)
--- @int i2 end row   (default N)
--- @int j2 end col   (default M)
+-- @tparam[opt=1] int|string i1 start row or spreadsheet range passed to `parse_range`
+-- @tparam[opt=1] int j1 start col
+-- @tparam[opt=N] int i2 end row
+-- @tparam[opt=M] int j2 end col
+-- @see parse_range
 -- @see tablex.set
 function array2d.set (t,value,i1,j1,i2,j2)
     i1,j1,i2,j2 = default_range(t,i1,j1,i2,j2)
@@ -1537,10 +1927,11 @@
 -- @array2d t a 2D array
 -- @param f a file object (default stdout)
 -- @string fmt a format string (default is just to use tostring)
--- @int i1 start row (default 1)
--- @int j1 start col (default 1)
--- @int i2 end row   (default N)
--- @int j2 end col   (default M)
+-- @tparam[opt=1] int|string i1 start row or spreadsheet range passed to `parse_range`
+-- @tparam[opt=1] int j1 start col
+-- @tparam[opt=N] int i2 end row
+-- @tparam[opt=M] int j2 end col
+-- @see parse_range
 function array2d.write (t,f,fmt,i1,j1,i2,j2)
     assert_arg(1,t,'table')
     f = f or stdout
@@ -1560,10 +1951,11 @@
 -- @array2d t 2D array
 -- @func row_op function to call on each value; `row_op(row,j)`
 -- @func end_row_op function to call at end of each row; `end_row_op(i)`
--- @int i1 start row (default 1)
--- @int j1 start col (default 1)
--- @int i2 end row   (default N)
--- @int j2 end col   (default M)
+-- @tparam[opt=1] int|string i1 start row or spreadsheet range passed to `parse_range`
+-- @tparam[opt=1] int j1 start col
+-- @tparam[opt=N] int i2 end row
+-- @tparam[opt=M] int j2 end col
+-- @see parse_range
 function array2d.forall (t,row_op,end_row_op,i1,j1,i2,j2)
     assert_arg(1,t,'table')
     i1,j1,i2,j2 = default_range(t,i1,j1,i2,j2)
@@ -1581,10 +1973,11 @@
 -- @int di start row in dest
 -- @int dj start col in dest
 -- @array2d src a 2D array
--- @int i1 start row (default 1)
--- @int j1 start col (default 1)
--- @int i2 end row   (default N)
--- @int j2 end col   (default M)
+-- @tparam[opt=1] int|string i1 start row or spreadsheet range passed to `parse_range`
+-- @tparam[opt=1] int j1 start col
+-- @tparam[opt=N] int i2 end row
+-- @tparam[opt=M] int j2 end col
+-- @see parse_range
 function array2d.move (dest,di,dj,src,i1,j1,i2,j2)
     assert_arg(1,dest,'table')
     assert_arg(4,src,'table')
@@ -1604,10 +1997,11 @@
 --- iterate over all elements in a 2D array, with optional indices.
 -- @array2d a 2D array
 -- @bool indices with indices (default false)
--- @int i1 start row (default 1)
--- @int j1 start col (default 1)
--- @int i2 end row   (default N)
--- @int j2 end col   (default M)
+-- @tparam[opt=1] int|string i1 start row or spreadsheet range passed to `parse_range`
+-- @tparam[opt=1] int j1 start col
+-- @tparam[opt=N] int i2 end row
+-- @tparam[opt=M] int j2 end col
+-- @see parse_range
 -- @return either `value` or `i,j,value` depending on the value of `indices`
 function array2d.iter(a,indices,i1,j1,i2,j2)
     assert_arg(1,a,'table')
@@ -1647,7 +2041,7 @@
 end
 
 --- iterate over all rows.
--- Returns a copy of the row, for read-only purrposes directly iterating
+-- Returns a copy of the row, for read-only purposes directly iterating
 -- is more performant; `ipairs(a)`
 -- @array2d a a 2D array
 -- @return row, row-index
@@ -2129,7 +2523,7 @@
 -- See `utils.unpack` for a version that is nil-safe.
 -- @param t table to unpack
 -- @param[opt] i index from which to start unpacking, defaults to 1
--- @param[opt] t index of the last element to unpack, defaults to #t
+-- @param[opt] j index of the last element to unpack, defaults to #t
 -- @return multiple return values from the table
 -- @function table.unpack
 -- @see utils.unpack
@@ -2191,7 +2585,7 @@
 -- @param ... any arguments
 if not warn then  -- luacheck: ignore
     local enabled = false
-    function warn(arg1, ...)  -- luacheck: ignore
+    local function warn(arg1, ...)  -- luacheck: ignore
         if type(arg1) == "string" and arg1:sub(1, 1) == "@" then
             -- control message
             if arg1 == "@on" then
@@ -2209,6 +2603,8 @@
           io.stderr:write("\n")
         end
     end
+    -- use rawset to bypass OpenResty's protection of global scope
+    rawset(_G, "warn", warn)
 end
 
 return compat
@@ -4122,7 +4518,7 @@
 
 --- Return a list of all file names within an array which match a pattern.
 -- @tab filenames An array containing file names.
--- @string pattern A shell pattern.
+-- @string pattern A shell pattern (see `fnmatch`).
 -- @treturn List(string) List of matching file names.
 -- @raise dir and mask must be strings
 function dir.filter(filenames,pattern)
@@ -4152,11 +4548,12 @@
 end
 
 --- return a list of all files in a directory which match a shell pattern.
--- @string dirname A directory. If not given, all files in current directory are returned.
--- @string mask  A shell pattern. If not given, all files are returned.
+-- @string[opt='.'] dirname A directory.
+-- @string[opt] mask A shell pattern (see `fnmatch`). If not given, all files are returned.
 -- @treturn {string} list of files
 -- @raise dirname and mask must be strings
 function dir.getfiles(dirname,mask)
+    dirname = dirname or '.'
     assert_dir(1,dirname)
     if mask then assert_string(2,mask) end
     local match
@@ -4170,10 +4567,11 @@
 end
 
 --- return a list of all subdirectories of the directory.
--- @string dirname A directory
+-- @string[opt='.'] dirname A directory.
 -- @treturn {string} a list of directories
--- @raise dir must be a a valid directory
+-- @raise dir must be a valid directory
 function dir.getdirectories(dirname)
+    dirname = dirname or '.'
     assert_dir(1,dirname)
     return _listfiles(dirname,false)
 end
@@ -4567,13 +4965,14 @@
 end
 
 
---- Recursively returns all the file starting at _path_. It can optionally take a shell pattern and
--- only returns files that match _shell_pattern_. If a pattern is given it will do a case insensitive search.
--- @string start_path  A directory. If not given, all files in current directory are returned.
--- @string shell_pattern A shell pattern. If not given, all files are returned.
--- @treturn List(string) containing all the files found recursively starting at _path_ and filtered by _shell_pattern_.
+--- Recursively returns all the file starting at 'path'. It can optionally take a shell pattern and
+-- only returns files that match 'shell_pattern'. If a pattern is given it will do a case insensitive search.
+-- @string[opt='.'] start_path  A directory.
+-- @string[opt='*'] shell_pattern A shell pattern (see `fnmatch`).
+-- @treturn List(string) containing all the files found recursively starting at 'path' and filtered by 'shell_pattern'.
 -- @raise start_path must be a directory
 function dir.getallfiles( start_path, shell_pattern )
+    start_path = start_path or '.'
     assert_dir(1,start_path)
     shell_pattern = shell_pattern or "*"
 
@@ -5113,7 +5512,7 @@
     if prevenvmt then
         _prev_index = prevenvmt.__index
         if prevenvmt.__newindex then
-            gmt.__index = prevenvmt.__newindex
+            gmt.__newindex = prevenvmt.__newindex
         end
     end
 
@@ -5634,7 +6033,7 @@
                         local enump = '|' .. enums .. '|'
                         vtype = 'string'
                         constraint = function(s)
-                            lapp.assert(enump:match('|'..s..'|'),
+                            lapp.assert(enump:find('|'..s..'|', 1, true),
                               "value '"..s.."' not in "..enums
                             )
                         end
@@ -10428,9 +10827,10 @@
 --
 -- See @{03-strings.md|the Guide}
 --
--- Dependencies: `pl.utils`
+-- Dependencies: `pl.utils`, `pl.types`
 -- @module pl.stringx
 local utils = require 'pl.utils'
+local is_callable = require 'pl.types'.is_callable
 local string = string
 local find = string.find
 local type,setmetatable,ipairs = type,setmetatable,ipairs
@@ -10441,10 +10841,13 @@
 local reverse = string.reverse
 local concat = table.concat
 local append = table.insert
+local remove = table.remove
 local escape = utils.escape
 local ceil, max = math.ceil, math.max
 local assert_arg,usplit = utils.assert_arg,utils.split
 local lstrip
+local unpack = utils.unpack
+local pack = utils.pack
 
 local function assert_string (n,s)
     assert_arg(n,s,'string')
@@ -10489,7 +10892,8 @@
     return find(s,'^%w+$') == 1
 end
 
---- does s only contain spaces?
+--- does s only contain whitespace?
+-- Matches on pattern '%s' so matches space, newline, tabs, etc.
 -- @string s a string
 function stringx.isspace(s)
     assert_string(1,s)
@@ -10631,10 +11035,14 @@
 -- @usage stringx.expandtabs('\tone,two,three', 4)   == '    one,two,three'
 -- @usage stringx.expandtabs('  \tone,two,three', 4) == '    one,two,three'
 function stringx.expandtabs(s,tabsize)
-    assert_string(1,s)
-    tabsize = tabsize or 8
-    return (s:gsub("([^\t\r\n]*)\t", function(before_tab)
+  assert_string(1,s)
+  tabsize = tabsize or 8
+  return (s:gsub("([^\t\r\n]*)\t", function(before_tab)
+      if tabsize == 0 then
+        return before_tab
+      else
         return before_tab .. (" "):rep(tabsize - #before_tab % tabsize)
+      end
     end))
 end
 
@@ -10808,10 +11216,10 @@
     return sub(s,f,t)
 end
 
---- trim any whitespace on the left of s.
+--- trim any characters on the left of s.
 -- @string s the string
 -- @string[opt='%s'] chrs default any whitespace character,
---  but can be a string of characters to be trimmed
+-- but can be a string of characters to be trimmed
 function stringx.lstrip(s,chrs)
     assert_string(1,s)
     return _strip(s,true,false,chrs)
@@ -10818,26 +11226,27 @@
 end
 lstrip = stringx.lstrip
 
---- trim any whitespace on the right of s.
+--- trim any characters on the right of s.
 -- @string s the string
 -- @string[opt='%s'] chrs default any whitespace character,
---  but can be a string of characters to be trimmed
+-- but can be a string of characters to be trimmed
 function stringx.rstrip(s,chrs)
     assert_string(1,s)
     return _strip(s,false,true,chrs)
 end
 
---- trim any whitespace on both left and right of s.
+--- trim any characters on both left and right of s.
 -- @string s the string
 -- @string[opt='%s'] chrs default any whitespace character,
---  but can be a string of characters to be trimmed
+-- but can be a string of characters to be trimmed
+-- @usage stringx.strip('  --== Hello ==--  ', "- =")  --> 'Hello'
 function stringx.strip(s,chrs)
     assert_string(1,s)
     return _strip(s,true,true,chrs)
 end
 
---- Partioning Strings
--- @section partioning
+--- Partitioning Strings
+-- @section partitioning
 
 --- split a string using a pattern. Note that at least one value will be returned!
 -- @string s the string
@@ -10864,7 +11273,7 @@
 
 --- partition the string using first occurance of a delimiter
 -- @string s the string
--- @string ch delimiter
+-- @string ch delimiter (match as plain string, no patterns)
 -- @return part before ch
 -- @return ch
 -- @return part after ch
@@ -10878,7 +11287,7 @@
 
 --- partition the string p using last occurance of a delimiter
 -- @string s the string
--- @string ch delimiter
+-- @string ch delimiter (match as plain string, no patterns)
 -- @return part before ch
 -- @return ch
 -- @return part after ch
@@ -10904,6 +11313,250 @@
     return sub(s,idx,idx)
 end
 
+
+--- Text handling
+-- @section text
+
+
+--- indent a multiline string.
+-- @tparam string s the (multiline) string
+-- @tparam integer n the size of the indent
+-- @tparam[opt=' '] string ch the character to use when indenting
+-- @return indented string
+function stringx.indent (s,n,ch)
+  assert_arg(1,s,'string')
+  assert_arg(2,n,'number')
+  local lines = usplit(s ,'\n')
+  local prefix = string.rep(ch or ' ',n)
+  for i, line in ipairs(lines) do
+    lines[i] = prefix..line
+  end
+  return concat(lines,'\n')..'\n'
+end
+
+
+--- dedent a multiline string by removing any initial indent.
+-- useful when working with [[..]] strings.
+-- Empty lines are ignored.
+-- @tparam string s the (multiline) string
+-- @return a string with initial indent zero.
+-- @usage
+-- local s = dedent [[
+--          One
+--
+--        Two
+--
+--      Three
+-- ]]
+-- assert(s == [[
+--     One
+--
+--   Two
+--
+-- Three
+-- ]])
+function stringx.dedent (s)
+  assert_arg(1,s,'string')
+  local lst = usplit(s,'\n')
+  if #lst>0 then
+    local ind_size = math.huge
+    for i, line in ipairs(lst) do
+      local i1, i2 = lst[i]:find('^%s*[^%s]')
+      if i1 and i2 < ind_size then
+        ind_size = i2
+      end
+    end
+    for i, line in ipairs(lst) do
+      lst[i] = lst[i]:sub(ind_size, -1)
+    end
+  end
+  return concat(lst,'\n')..'\n'
+end
+
+
+
+do
+  local buildline = function(words, size, breaklong)
+    -- if overflow is set, a word longer than size, will overflow the size
+    -- otherwise it will be chopped in line-length pieces
+    local line = {}
+    if #words[1] > size then
+      -- word longer than line
+      if not breaklong then
+        line[1] = words[1]
+        remove(words, 1)
+      else
+        line[1] = words[1]:sub(1, size)
+        words[1] = words[1]:sub(size + 1, -1)
+      end
+    else
+      local len = 0
+      while words[1] and (len + #words[1] <= size) or
+            (len == 0 and #words[1] == size) do
+        if words[1] ~= "" then
+          line[#line+1] = words[1]
+          len = len + #words[1] + 1
+        end
+        remove(words, 1)
+      end
+    end
+    return stringx.strip(concat(line, " ")), words
+  end
+
+  --- format a paragraph into lines so that they fit into a line width.
+  -- It will not break long words by default, so lines can be over the length
+  -- to that extent.
+  -- @tparam string s the string to format
+  -- @tparam[opt=70] integer width the margin width
+  -- @tparam[opt=false] boolean breaklong if truthy, words longer than the width given will be forced split.
+  -- @return a list of lines (List object), use `fill` to return a string instead of a `List`.
+  -- @see pl.List
+  -- @see fill
+  stringx.wrap = function(s, width, breaklong)
+    s = s:gsub('\n',' ') -- remove line breaks
+    s = stringx.strip(s) -- remove leading/trailing whitespace
+    if s == "" then
+      return { "" }
+    end
+    width = width or 70
+    local out = {}
+    local words = usplit(s, "%s")
+    while words[1] do
+      out[#out+1], words = buildline(words, width, breaklong)
+    end
+    return makelist(out)
+  end
+end
+
+--- format a paragraph so that it fits into a line width.
+-- @tparam string s the string to format
+-- @tparam[opt=70] integer width the margin width
+-- @tparam[opt=false] boolean breaklong if truthy, words longer than the width given will be forced split.
+-- @return a string, use `wrap` to return a list of lines instead of a string.
+-- @see wrap
+function stringx.fill (s,width,breaklong)
+  return concat(stringx.wrap(s,width,breaklong),'\n') .. '\n'
+end
+
+--- Template
+-- @section Template
+
+
+local function _substitute(s,tbl,safe)
+  local subst
+  if is_callable(tbl) then
+    subst = tbl
+  else
+    function subst(f)
+      local s = tbl[f]
+      if not s then
+        if safe then
+          return f
+        else
+          error("not present in table "..f)
+        end
+      else
+        return s
+      end
+    end
+  end
+  local res = gsub(s,'%${([%w_]+)}',subst)
+  return (gsub(res,'%$([%w_]+)',subst))
+end
+
+
+
+local Template = {}
+stringx.Template = Template
+Template.__index = Template
+setmetatable(Template, {
+  __call = function(obj,tmpl)
+    return Template.new(tmpl)
+  end
+})
+
+--- Creates a new Template class.
+-- This is a shortcut to `Template.new(tmpl)`.
+-- @tparam string tmpl the template string
+-- @function Template
+-- @treturn Template
+function Template.new(tmpl)
+  assert_arg(1,tmpl,'string')
+  local res = {}
+  res.tmpl = tmpl
+  setmetatable(res,Template)
+  return res
+end
+
+--- substitute values into a template, throwing an error.
+-- This will throw an error if no name is found.
+-- @tparam table tbl a table of name-value pairs.
+-- @return string with place holders substituted
+function Template:substitute(tbl)
+  assert_arg(1,tbl,'table')
+  return _substitute(self.tmpl,tbl,false)
+end
+
+--- substitute values into a template.
+-- This version just passes unknown names through.
+-- @tparam table tbl a table of name-value pairs.
+-- @return string with place holders substituted
+function Template:safe_substitute(tbl)
+  assert_arg(1,tbl,'table')
+  return _substitute(self.tmpl,tbl,true)
+end
+
+--- substitute values into a template, preserving indentation. <br>
+-- If the value is a multiline string _or_ a template, it will insert
+-- the lines at the correct indentation. <br>
+-- Furthermore, if a template, then that template will be substituted
+-- using the same table.
+-- @tparam table tbl a table of name-value pairs.
+-- @return string with place holders substituted
+function Template:indent_substitute(tbl)
+  assert_arg(1,tbl,'table')
+  if not self.strings then
+    self.strings = usplit(self.tmpl,'\n')
+  end
+
+  -- the idea is to substitute line by line, grabbing any spaces as
+  -- well as the $var. If the value to be substituted contains newlines,
+  -- then we split that into lines and adjust the indent before inserting.
+  local function subst(line)
+    return line:gsub('(%s*)%$([%w_]+)',function(sp,f)
+      local subtmpl
+      local s = tbl[f]
+      if not s then error("not present in table "..f) end
+      if getmetatable(s) == Template then
+        subtmpl = s
+        s = s.tmpl
+      else
+        s = tostring(s)
+      end
+      if s:find '\n' then
+        local lines = usplit(s, '\n')
+        for i, line in ipairs(lines) do
+          lines[i] = sp..line
+        end
+        s = concat(lines, '\n') .. '\n'
+      end
+      if subtmpl then
+        return _substitute(s, tbl)
+      else
+        return s
+      end
+    end)
+  end
+
+  local lines = {}
+  for i, line in ipairs(self.strings) do
+    lines[i] = subst(line)
+  end
+  return concat(lines,'\n')..'\n'
+end
+
+
+
 --- Miscelaneous
 -- @section misc
 
@@ -10936,79 +11589,148 @@
 
 stringx.capitalize = stringx.title
 
-local ellipsis = '...'
-local n_ellipsis = #ellipsis
+do
+  local ellipsis = '...'
+  local n_ellipsis = #ellipsis
 
---- Return a shortened version of a string.
--- Fits string within w characters. Removed characters are marked with ellipsis.
--- @string s the string
--- @int w the maxinum size allowed
--- @bool tail true if we want to show the end of the string (head otherwise)
--- @usage ('1234567890'):shorten(8) == '12345...'
--- @usage ('1234567890'):shorten(8, true) == '...67890'
--- @usage ('1234567890'):shorten(20) == '1234567890'
-function stringx.shorten(s,w,tail)
-    assert_string(1,s)
-    if #s > w then
-        if w < n_ellipsis then return ellipsis:sub(1,w) end
-        if tail then
-            local i = #s - w + 1 + n_ellipsis
-            return ellipsis .. s:sub(i)
-        else
-            return s:sub(1,w-n_ellipsis) .. ellipsis
-        end
-    end
-    return s
+  --- Return a shortened version of a string.
+  -- Fits string within w characters. Removed characters are marked with ellipsis.
+  -- @string s the string
+  -- @int w the maxinum size allowed
+  -- @bool tail true if we want to show the end of the string (head otherwise)
+  -- @usage ('1234567890'):shorten(8) == '12345...'
+  -- @usage ('1234567890'):shorten(8, true) == '...67890'
+  -- @usage ('1234567890'):shorten(20) == '1234567890'
+  function stringx.shorten(s,w,tail)
+      assert_string(1,s)
+      if #s > w then
+          if w < n_ellipsis then return ellipsis:sub(1,w) end
+          if tail then
+              local i = #s - w + 1 + n_ellipsis
+              return ellipsis .. s:sub(i)
+          else
+              return s:sub(1,w-n_ellipsis) .. ellipsis
+          end
+      end
+      return s
+  end
 end
 
---- Utility function that finds any patterns that match a long string's an open or close.
--- Note that having this function use the least number of equal signs that is possible is a harder algorithm to come up with.
--- Right now, it simply returns the greatest number of them found.
--- @param s The string
--- @return 'nil' if not found. If found, the maximum number of equal signs found within all matches.
-local function has_lquote(s)
-    local lstring_pat = '([%[%]])(=*)%1'
-    local equals, new_equals, _
-    local finish = 1
-    repeat
-        _, finish, _, new_equals = s:find(lstring_pat, finish)
-        if new_equals then
-            equals = max(equals or 0, #new_equals)
-        end
-    until not new_equals
 
-    return equals
+do
+  -- Utility function that finds any patterns that match a long string's an open or close.
+  -- Note that having this function use the least number of equal signs that is possible is a harder algorithm to come up with.
+  -- Right now, it simply returns the greatest number of them found.
+  -- @param s The string
+  -- @return 'nil' if not found. If found, the maximum number of equal signs found within all matches.
+  local function has_lquote(s)
+      local lstring_pat = '([%[%]])(=*)%1'
+      local equals, new_equals, _
+      local finish = 1
+      repeat
+          _, finish, _, new_equals = s:find(lstring_pat, finish)
+          if new_equals then
+              equals = max(equals or 0, #new_equals)
+          end
+      until not new_equals
+
+      return equals
+  end
+
+  --- Quote the given string and preserve any control or escape characters, such that reloading the string in Lua returns the same result.
+  -- @param s The string to be quoted.
+  -- @return The quoted string.
+  function stringx.quote_string(s)
+      assert_string(1,s)
+      -- Find out if there are any embedded long-quote sequences that may cause issues.
+      -- This is important when strings are embedded within strings, like when serializing.
+      -- Append a closing bracket to catch unfinished long-quote sequences at the end of the string.
+      local equal_signs = has_lquote(s .. "]")
+
+      -- Note that strings containing "\r" can't be quoted using long brackets
+      -- as Lua lexer converts all newlines to "\n" within long strings.
+      if (s:find("\n") or equal_signs) and not s:find("\r") then
+          -- If there is an embedded sequence that matches a long quote, then
+          -- find the one with the maximum number of = signs and add one to that number.
+          equal_signs = ("="):rep((equal_signs or -1) + 1)
+          -- Long strings strip out leading newline. We want to retain that, when quoting.
+          if s:find("^\n") then s = "\n" .. s end
+          local lbracket, rbracket =
+              "[" .. equal_signs .. "[",
+              "]" .. equal_signs .. "]"
+          s = lbracket .. s .. rbracket
+      else
+          -- Escape funny stuff. Lua 5.1 does not handle "\r" correctly.
+          s = ("%q"):format(s):gsub("\r", "\\r")
+      end
+      return s
+  end
 end
 
---- Quote the given string and preserve any control or escape characters, such that reloading the string in Lua returns the same result.
--- @param s The string to be quoted.
--- @return The quoted string.
-function stringx.quote_string(s)
-    assert_string(1,s)
-    -- Find out if there are any embedded long-quote sequences that may cause issues.
-    -- This is important when strings are embedded within strings, like when serializing.
-    -- Append a closing bracket to catch unfinished long-quote sequences at the end of the string.
-    local equal_signs = has_lquote(s .. "]")
 
-    -- Note that strings containing "\r" can't be quoted using long brackets
-    -- as Lua lexer converts all newlines to "\n" within long strings.
-    if (s:find("\n") or equal_signs) and not s:find("\r") then
-        -- If there is an embedded sequence that matches a long quote, then
-        -- find the one with the maximum number of = signs and add one to that number.
-        equal_signs = ("="):rep((equal_signs or -1) + 1)
-        -- Long strings strip out leading newline. We want to retain that, when quoting.
-        if s:find("^\n") then s = "\n" .. s end
-        local lbracket, rbracket =
-            "[" .. equal_signs .. "[",
-            "]" .. equal_signs .. "]"
-        s = lbracket .. s .. rbracket
+--- Python-style formatting operator.
+-- Calling `text.format_operator()` overloads the % operator for strings to give
+-- Python/Ruby style formated output.
+-- This is extended to also do template-like substitution for map-like data.
+--
+-- Note this goes further than the original, and will allow these cases:
+--
+-- 1. a single value
+-- 2. a list of values
+-- 3. a map of var=value pairs
+-- 4. a function, as in gsub
+--
+-- For the second two cases, it uses $-variable substituion.
+--
+-- When called, this function will monkey-patch the global `string` metatable by
+-- adding a `__mod` method.
+--
+-- See <a href="http://lua-users.org/wiki/StringInterpolation">the lua-users wiki</a>
+--
+-- @usage
+-- require 'pl.text'.format_operator()
+-- local out1 = '%s = %5.3f' % {'PI',math.pi}                   --> 'PI = 3.142'
+-- local out2 = '$name = $value' % {name='dog',value='Pluto'}   --> 'dog = Pluto'
+function stringx.format_operator()
+
+  local format = string.format
+
+  -- a more forgiving version of string.format, which applies
+  -- tostring() to any value with a %s format.
+  local function formatx (fmt,...)
+    local args = pack(...)
+    local i = 1
+    for p in fmt:gmatch('%%.') do
+      if p == '%s' and type(args[i]) ~= 'string' then
+        args[i] = tostring(args[i])
+      end
+      i = i + 1
+    end
+    return format(fmt,unpack(args))
+  end
+
+  local function basic_subst(s,t)
+    return (s:gsub('%$([%w_]+)',t))
+  end
+
+  getmetatable("").__mod = function(a, b)
+    if b == nil then
+      return a
+    elseif type(b) == "table" and getmetatable(b) == nil then
+      if #b == 0 then -- assume a map-like table
+        return _substitute(a,b,true)
+      else
+        return formatx(a,unpack(b))
+      end
+    elseif type(b) == 'function' then
+      return basic_subst(a,b)
     else
-        -- Escape funny stuff. Lua 5.1 does not handle "\r" correctly.
-        s = ("%q"):format(s):gsub("\r", "\\r")
+      return formatx(a,b)
     end
-    return s
+  end
 end
 
+--- import the stringx functions into the global string (meta)table
 function stringx.import()
     utils.import(stringx,string)
 end
@@ -12413,247 +13135,27 @@
 -- libraries, see string.Template). It also provides similar functions to those
 -- found in the textwrap module.
 --
+-- IMPORTANT: this module has been deprecated and will be removed in a future
+-- version (2.0). The contents of this module have moved to the `pl.stringx`
+-- module.
+--
 -- See  @{03-strings.md.String_Templates|the Guide}.
 --
--- Calling `text.format_operator()` overloads the % operator for strings to give Python/Ruby style formated output.
--- This is extended to also do template-like substitution for map-like data.
---
---    > require 'pl.text'.format_operator()
---    > = '%s = %5.3f' % {'PI',math.pi}
---    PI = 3.142
---    > = '$name = $value' % {name='dog',value='Pluto'}
---    dog = Pluto
---
--- Dependencies: `pl.utils`, `pl.types`
+-- Dependencies: `pl.stringx`, `pl.utils`
 -- @module pl.text
 
-local gsub = string.gsub
-local concat,append = table.concat,table.insert
-local utils = require 'pl.utils'
-local bind1,usplit,assert_arg = utils.bind1,utils.split,utils.assert_arg
-local is_callable = require 'pl.types'.is_callable
-local unpack = utils.unpack
+local utils = require("pl.utils")
 
-local text = {}
+utils.raise_deprecation {
+  source = "Penlight " .. utils._VERSION,
+  message = "the contents of module 'pl.text' has moved into 'pl.stringx'",
+  version_removed = "2.0.0",
+  deprecated_after = "1.11.0",
+  no_trace = true,
+}
 
+return require "pl.stringx"
 
-local function makelist(l)
-    return setmetatable(l, require('pl.List'))
-end
-
-local function lstrip(str)  return (str:gsub('^%s+',''))  end
-local function strip(str)  return (lstrip(str):gsub('%s+$','')) end
-local function split(s,delim)  return makelist(usplit(s,delim)) end
-
-local function imap(f,t,...)
-    local res = {}
-    for i = 1,#t do res[i] = f(t[i],...) end
-    return res
-end
-
-local function _indent (s,sp)
-    local sl = split(s,'\n')
-    return concat(imap(bind1('..',sp),sl),'\n')..'\n'
-end
-
---- indent a multiline string.
--- @param s the string
--- @param n the size of the indent
--- @param ch the character to use when indenting (default ' ')
--- @return indented string
-function text.indent (s,n,ch)
-    assert_arg(1,s,'string')
-    assert_arg(2,n,'number')
-    return _indent(s,string.rep(ch or ' ',n))
-end
-
---- dedent a multiline string by removing any initial indent.
--- useful when working with [[..]] strings.
--- @param s the string
--- @return a string with initial indent zero.
-function text.dedent (s)
-    assert_arg(1,s,'string')
-    local sl = split(s,'\n')
-    local _,i2 = (#sl>0 and sl[1] or ''):find('^%s*')
-    sl = imap(string.sub,sl,i2+1)
-    return concat(sl,'\n')..'\n'
-end
-
---- format a paragraph into lines so that they fit into a line width.
--- It will not break long words, so lines can be over the length
--- to that extent.
--- @param s the string
--- @param width the margin width, default 70
--- @return a list of lines (List object)
--- @see pl.List
-function text.wrap (s,width)
-    assert_arg(1,s,'string')
-    width = width or 70
-    s = s:gsub('\n',' ')
-    local i,nxt = 1
-    local lines,line = {}
-    while i < #s do
-        nxt = i+width
-        if s:find("[%w']",nxt) then -- inside a word
-            nxt = s:find('%W',nxt+1) -- so find word boundary
-        end
-        line = s:sub(i,nxt)
-        i = i + #line
-        append(lines,strip(line))
-    end
-    return makelist(lines)
-end
-
---- format a paragraph so that it fits into a line width.
--- @param s the string
--- @param width the margin width, default 70
--- @return a string
--- @see wrap
-function text.fill (s,width)
-    return concat(text.wrap(s,width),'\n') .. '\n'
-end
-
-local Template = {}
-text.Template = Template
-Template.__index = Template
-setmetatable(Template, {
-    __call = function(obj,tmpl)
-        return Template.new(tmpl)
-    end})
-
-function Template.new(tmpl)
-    assert_arg(1,tmpl,'string')
-    local res = {}
-    res.tmpl = tmpl
-    setmetatable(res,Template)
-    return res
-end
-
-local function _substitute(s,tbl,safe)
-    local subst
-    if is_callable(tbl) then
-        subst = tbl
-    else
-        function subst(f)
-            local s = tbl[f]
-            if not s then
-                if safe then
-                    return f
-                else
-                    error("not present in table "..f)
-                end
-            else
-                return s
-            end
-        end
-    end
-    local res = gsub(s,'%${([%w_]+)}',subst)
-    return (gsub(res,'%$([%w_]+)',subst))
-end
-
---- substitute values into a template, throwing an error.
--- This will throw an error if no name is found.
--- @param tbl a table of name-value pairs.
-function Template:substitute(tbl)
-    assert_arg(1,tbl,'table')
-    return _substitute(self.tmpl,tbl,false)
-end
-
---- substitute values into a template.
--- This version just passes unknown names through.
--- @param tbl a table of name-value pairs.
-function Template:safe_substitute(tbl)
-    assert_arg(1,tbl,'table')
-    return _substitute(self.tmpl,tbl,true)
-end
-
---- substitute values into a template, preserving indentation. <br>
--- If the value is a multiline string _or_ a template, it will insert
--- the lines at the correct indentation. <br>
--- Furthermore, if a template, then that template will be subsituted
--- using the same table.
--- @param tbl a table of name-value pairs.
-function Template:indent_substitute(tbl)
-    assert_arg(1,tbl,'table')
-    if not self.strings then
-        self.strings = split(self.tmpl,'\n')
-    end
-    -- the idea is to substitute line by line, grabbing any spaces as
-    -- well as the $var. If the value to be substituted contains newlines,
-    -- then we split that into lines and adjust the indent before inserting.
-    local function subst(line)
-        return line:gsub('(%s*)%$([%w_]+)',function(sp,f)
-            local subtmpl
-            local s = tbl[f]
-            if not s then error("not present in table "..f) end
-            if getmetatable(s) == Template then
-                subtmpl = s
-                s = s.tmpl
-            else
-                s = tostring(s)
-            end
-            if s:find '\n' then
-                s = _indent(s,sp)
-            end
-            if subtmpl then return _substitute(s,tbl)
-            else return s
-            end
-        end)
-    end
-    local lines = imap(subst,self.strings)
-    return concat(lines,'\n')..'\n'
-end
-
-------- Python-style formatting operator ------
--- (see <a href="http://lua-users.org/wiki/StringInterpolation">the lua-users wiki</a>) --
-
-function text.format_operator()
-
-    local format = string.format
-
-    -- a more forgiving version of string.format, which applies
-    -- tostring() to any value with a %s format.
-    local function formatx (fmt,...)
-        local args = {...}
-        local i = 1
-        for p in fmt:gmatch('%%.') do
-            if p == '%s' and type(args[i]) ~= 'string' then
-                args[i] = tostring(args[i])
-            end
-            i = i + 1
-        end
-        return format(fmt,unpack(args))
-    end
-
-    local function basic_subst(s,t)
-        return (s:gsub('%$([%w_]+)',t))
-    end
-
-    -- Note this goes further than the original, and will allow these cases:
-    -- 1. a single value
-    -- 2. a list of values
-    -- 3. a map of var=value pairs
-    -- 4. a function, as in gsub
-    -- For the second two cases, it uses $-variable substituion.
-    getmetatable("").__mod = function(a, b)
-        if b == nil then
-            return a
-        elseif type(b) == "table" and getmetatable(b) == nil then
-            if #b == 0 then -- assume a map-like table
-                return _substitute(a,b,true)
-            else
-                return formatx(a,unpack(b))
-            end
-        elseif type(b) == 'function' then
-            return basic_subst(a,b)
-        else
-            return formatx(a,b)
-        end
-    end
-end
-
-return text
-
 end,
 
 ["pl.types"] = function()
@@ -12919,7 +13421,10 @@
 local compat = require 'pl.compat'
 local stdout = io.stdout
 local append = table.insert
+local concat = table.concat
 local _unpack = table.unpack  -- always injected by 'compat'
+local find = string.find
+local sub = string.sub
 
 local is_windows = compat.is_windows
 local err_mode = 'default'
@@ -12928,7 +13433,7 @@
 local _function_factories = {}
 
 
-local utils = { _VERSION = "1.11.0" }
+local utils = { _VERSION = "1.12.0" }
 for k, v in pairs(compat) do utils[k] = v  end
 
 --- Some standard patterns
@@ -12970,7 +13475,7 @@
 -- that this one DOES honor the `n` field in the table `t`, such that it is 'nil-safe'.
 -- @param t table to unpack
 -- @param[opt] i index from which to start unpacking, defaults to 1
--- @param[opt] t index of the last element to unpack, defaults to `t.n` or `#t`
+-- @param[opt] j index of the last element to unpack, defaults to `t.n` or else `#t`
 -- @return multiple return values from the table
 -- @function utils.unpack
 -- @see compat.unpack
@@ -13156,6 +13661,68 @@
     return val
 end
 
+--- creates an Enum table.
+-- This helps prevent magic strings in code by throwing errors for accessing
+-- non-existing values.
+--
+-- Calling on the object does the same, but returns a soft error; `nil + err`.
+--
+-- The values are equal to the keys. The enum object is
+-- read-only.
+-- @param ... strings that make up the enumeration.
+-- @return Enum object
+-- @usage -- accessing at runtime
+-- local obj = {}
+-- obj.MOVEMENT = utils.enum("FORWARD", "REVERSE", "LEFT", "RIGHT")
+--
+-- if current_movement == obj.MOVEMENT.FORWARD then
+--   -- do something
+--
+-- elseif current_movement == obj.MOVEMENT.REVERES then
+--   -- throws error due to typo 'REVERES', so a silent mistake becomes a hard error
+--   -- "'REVERES' is not a valid value (expected one of: 'FORWARD', 'REVERSE', 'LEFT', 'RIGHT')"
+--
+-- end
+-- @usage -- validating user-input
+-- local parameter = "...some user provided option..."
+-- local ok, err = obj.MOVEMENT(parameter) -- calling on the object
+-- if not ok then
+--   print("bad 'parameter', " .. err)
+--   os.exit(1)
+-- end
+function utils.enum(...)
+  local lst = utils.pack(...)
+  utils.assert_arg(1, lst[1], "string") -- at least 1 string
+
+  local enum = {}
+  for i, value in ipairs(lst) do
+    utils.assert_arg(i, value, "string")
+    enum[value] = value
+  end
+
+  local valid = "(expected one of: '" .. concat(lst, "', '") .. "')"
+  setmetatable(enum, {
+    __index = function(self, key)
+      error(("'%s' is not a valid value %s"):format(tostring(key), valid), 2)
+    end,
+    __newindex = function(self, key, value)
+      error("the Enum object is read-only", 2)
+    end,
+    __call = function(self, key)
+      if type(key) == "string" then
+        local v = rawget(self, key)
+        if v then
+          return v
+        end
+      end
+      return nil, ("'%s' is not a valid value %s"):format(tostring(key), valid)
+    end
+  })
+
+  return enum
+end
+
+
 --- process a function argument.
 -- This is used throughout Penlight and defines what is meant by a function:
 -- Something that is callable, or an operator string as defined by <code>pl.operator</code>,
@@ -13367,7 +13934,7 @@
             r[i] = utils.quote_arg(arg)
         end
 
-        return table.concat(r, " ")
+        return concat(r, " ")
     end
     -- only a single argument
     if is_windows then
@@ -13436,7 +14003,6 @@
 -- @see splitv
 function utils.split(s,re,plain,n)
     utils.assert_string(1,s)
-    local find,sub,append = string.find, string.sub, table.insert
     local i1,ls = 1,{}
     if not re then re = '%s+' end
     if re == '' then return {s} end

Modified: trunk/Master/texmf-dist/tex/luatex/penlight/penlight.sty
===================================================================
--- trunk/Master/texmf-dist/tex/luatex/penlight/penlight.sty	2022-02-27 21:33:55 UTC (rev 62253)
+++ trunk/Master/texmf-dist/tex/luatex/penlight/penlight.sty	2022-02-27 21:34:10 UTC (rev 62254)
@@ -1,5 +1,5 @@
 % Kale Ewasiuk (kalekje at gmail.com)
-% 2021-12-15
+% 2022-02-27
 % Copyright (C) 2021 Kale Ewasiuk
 %
 % Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -22,7 +22,7 @@
 % OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
 % OR OTHER DEALINGS IN THE SOFTWARE.
 
-\ProvidesPackage{penlight}[2021-12-15]
+\ProvidesPackage{penlight}[2022-02-27]
 
 \RequirePackage{luacode}
 
@@ -34,9 +34,15 @@
     penlight = require('penlight')
     __PENLIGHT__ = 'penlight'
 }}
+
+
 \DeclareOption{stringx}{\luadirect{_G[__PENLIGHT__].stringx.import()}}
-\DeclareOption{format}{\luadirect{_G[__PENLIGHT__].text.format_operator()}}
+\DeclareOption{format}{\luadirect{_G[__PENLIGHT__].stringx.format_operator()}}
 \DeclareOption{func}{\luadirect{_G[__PENLIGHT__].utils.import(__PENLIGHT__..'.func')}}
 \DeclareOption{extras}{\luadirect{require('penlightextras')}}
+\DeclareOption{extrasnoglobals}{
+    __PL_NO_GLOBALS__ = true
+    \luadirect{require('penlightextras')
+    }}
 
 \ProcessOptions*\relax

Modified: trunk/Master/texmf-dist/tex/luatex/penlight/penlightextras.lua
===================================================================
--- trunk/Master/texmf-dist/tex/luatex/penlight/penlightextras.lua	2022-02-27 21:33:55 UTC (rev 62253)
+++ trunk/Master/texmf-dist/tex/luatex/penlight/penlightextras.lua	2022-02-27 21:34:10 UTC (rev 62254)
@@ -1,174 +1,266 @@
+
+__SKIP_TEX__ = __SKIP_TEX__ or false --if declared true before here, it will use regular print functions
+--                                       (for troubleshooting with texlua instead of actual use in lua latex)
+
+__PL_NO_GLOBALS__ = __PL_NO_GLOBALS__ or false
+
 -- requires penlight
 local pl = _G['penlight'] or _G['pl'] -- penlight for this namespace is pl
-local bind = bind or pl.func.bind
 
--- some bonus string operations, % text operator, and functional programmng
+
+-- some bonus string operations, % text operator, and functional programming
 pl.stringx.import()
 pl.text.format_operator()
-pl.utils.import('pl.func')
+pl.utils.import('pl.func') -- allow placeholder expressions _1 +1 etc.
 
-function help_wrt(s1, s2) -- helpful printing, makes it easy to debug, s1 is object, s2 is note
-    local wrt = wrt or texio.write_nl
-    local wrt = wrt or print
+pl.COMP = require'pl.comprehension'.new() -- for comprehensions
+
+-- http://lua-users.org/wiki/SplitJoin -- todo read me!!
+
+pl.tex = {} -- adding a sub-module for tex related stuff
+
+local bind = bind or pl.func.bind
+
+
+function pl.hasval(x)  -- if something has value
+    if (type(x) == 'function') or (type(x) == 'CFunction') or (type(x) == 'userdata') then
+        return true
+    elseif (x == nil) or (x == false) or (x == 0) or (x == '') or (x == {}) then
+        return false
+    elseif (type(x) ~= 'boolean') and (type(x) ~= 'number') and (type(x) ~= 'string') then  -- something else? maybe ths not needed
+        if #x == 0 then -- one more check, probably no needed though, I was trying to cover other classes but they all tables
+            return false
+        else
+            return true
+        end
+    end
+    return true
+end
+
+
+-- Some simple and helpful LaTeX functions -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+
+-- xparse defaults
+pl.tex._xTrue = '\\BooleanTrue '
+pl.tex._xFalse = '\\BooleanFalse '
+pl.tex._xNoValue = '-NoValue-'
+
+--Generic LuaLaTeX utilities for print commands or environments
+
+if not __SKIP_TEX__ then
+    local function check_special_chars(s) -- todo extend to toher special chars?
+        if type(s) == 'string' then
+            if string.find(s, '[\n\r\t\0]') then
+                pl.tex.pkgwarn('penlight', 'printing string with special (eg. newline) char, possible unexpected behaviour on string: '..s)
+            end
+        end
+    end
+
+    -- NOTE: usage is a bit different than default. If number is first arg, you CANT change catcode.
+    --              We don't need that under normal use, use tex.print or tex.sprint if you need
+    function pl.tex.prt(s, ...) -- print something, no new line after
+        check_special_chars(s)
+        if type(s) == 'number' then s = tostring(s) end
+        tex.sprint(s, ...)     --can print lists as well, but will NOT put new line between them or anything printed
+    end
+
+    function pl.tex.prtn(s, ...) -- print with new line after, can print lists or nums. C-function not in Lua, apparantly
+        s = s or ''
+        check_special_chars(s)
+        if type(s) == 'number' then s = tostring(s) end
+        tex.print(s, ...)
+    end
+
+    pl.tex.wrt = texio.write
+    pl.tex.wrtn = texio.write_nl
+else
+    pl.tex.prt = io.write
+    pl.tex.prtn = print     --print with new line
+    pl.tex.wrt = io.write
+    pl.tex.wrtn = io.write_nl
+end
+
+function pl.tex.prtl(str) -- prints a literal/lines string in latex, adds new line between them
+    for line in str:gmatch"[^\n]*" do  -- gets all characters up to a new line
+        pl.tex.prtn(line)
+    end
+end
+
+-- todo option to specify between character? one for first table, on for recursives?
+function pl.tex.prtt(tab, d1, d2) -- prints a table with new line between each item
+    d1 = d1 or ''
+    d2 = d2 or '\\leavevmode\\\\'
+    for _, t in pairs(tab) do  --
+        if type(t) ~= 'table' then
+            if d1 == '' then
+                pl.tex.prtn(t)
+            else
+                pl.tex.prt(t, d1)
+            end
+         else
+            pl.tex.prtn(d2)
+            pl.tex.prtt(t,d1,d2)
+        end
+    end
+end
+
+function pl.tex.help_wrt(s1, s2) -- helpful printing, makes it easy to debug, s1 is object, s2 is note
+    local wrt2 = wrt or texio.write_nl or print
     s2 = s2 or ''
-    wrt('\nvvvvv '..s2..'\n')
+    wrt2('\nvvvvv '..s2..'\n')
     if type(s1) == 'table' then
-        wrt(pl.pretty.write(s1))
+        wrt2(pl.pretty.write(s1))
     else
-        wrt(tostring(s1))
+        wrt2(tostring(s1))
     end
-    wrt('\n^^^^^\n')
+    wrt2('\n^^^^^\n')
 end
 
-function prt_array2d(t)
+function pl.tex.prt_array2d(t)
     for _, r in ipairs(t) do
         local s = ''
         for _, v in ipairs(r) do
             s = s.. tostring(v)..', '
         end
-        print(s)
+        pl.tex.prt(s)
+        pl.tex.prt('\n')
     end
 end
 
 -- -- -- -- --
 
-
--- -- -- --  functions below are helpers for arrays and 2d
-
-local function compare_elements(a, b, op, ele)
-    op = op or pl.oper.gt
-    ele = ele or 1
-    return op(a[ele], b[ele])
+function pl.tex.pkgwarn(pkg, msg1, msg2)
+    pkg = pkg or ''
+    msg1 = msg1 or ''
+    msg2 = msg2 or ''
+    tex.sprint('\\PackageWarning{'..pkg..'}{'..msg1..'}{'..msg2..'}')
 end
 
-local function comp_2ele_func(op, ele) -- make a 2 element comparison function,
-    --sort with function on element nnum
-    return bind(compare_elements, _1, _2, op, ele)
+function pl.tex.pkgerror(pkg, msg1, msg2, stop)
+    pkg = pkg or ''
+    msg1 = msg1 or ''
+    msg2 = msg2 or ''
+    stop = pl.hasval(stop)
+    tex.sprint('\\PackageError{'..pkg..'}{'..msg1..'}{'..msg2..'}')
+    if stop then tex.sprint('\\stop') end -- stop on the spot (say that 10 times)
 end
 
 
--- -- -- --
+--definition helpers -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
 
-local function check_index(ij, rc) -- converts array index to positive value if negative
-    if type(ij) ~= 'number' then
-        return 1
+function pl.tex.defcmd(cs, val) -- simple definitions
+    val = val or ''
+    token.set_macro(cs, val, 'global')
+end
+
+function pl.tex.prvcmd(cs, val) -- provide command via lua
+   if token.is_defined(cs) then
+       -- do nothing if token is defined already --pkgwarn('penlight', 'Definition '..cs..' is being overwritten')
     else
-        if ij < 0 then
-            ij = rc + ij + 1
-        elseif ij > rc then
-            ij = rc
-        end
-        return ij
+        pl.tex.defcmd(cs, val)
     end
 end
-local function check_slice(M, i1, j1, i2, j2) -- ensure a slice is valid; i.e. all positive numbers
-    r, c = pl.array2d.size(M)
-    i1 = check_index(i1, r)
-    i2 = check_index(i2, r)
-    j1 = check_index(j1, c)
-    j2 = check_index(j2, c)
-    return i1, j1, i2, j2
-end
 
-local function check_func(func)  -- check if a function is a PE, if so, make it a function
-    if type(func) ~= 'function' then
-        __func = I(func)
+function pl.tex.newcmd(cs, val) -- provide command via lua
+   if token.is_defined(cs) then
+       pl.tex.pkgerror('penlight: newcmd',cs..' already defined')
+    else
+        pl.tex.defcmd(cs, val)
     end
-    return __func
 end
 
-
--- -- -- -- -- -- --
--- -- -- --  functions below extend the array2d module
-
-
-function pl.array2d.map_slice1(func, L, i1, i2) -- map a function to a slice of an array, can use PlcExpr
-    i2 = i2 or i1
-    local len = #L
-    i1 = check_index(i1, len)
-    i2 = check_index(i2, len)
-    func = check_func(func)
-    for i in pl.seq.range(i1,i2) do
-            L[i] = func(L[i])
-        end
-   return L
+function pl.tex.renewcmd(cs, val) -- provide command via lua
+   if token.is_defined(cs) then
+        pl.tex.defcmd(cs, val)
+    else
+        pl.tex.pkgerror('penlight: renewcmd',cs..' not defined')
+    end
 end
 
-function pl.array2d.map_slice2(func, M, i1, j1, i2, j2) -- map a function to a slice of a Matrix
-    i1, j1, i2, j2 = check_slice(M, i1, j1, i2, j2)
-    --for i,j in array2d.iter(M, true, i1, j1, i2, j2) do  --todo this did not work, penlight may have fixed this
-    func = check_func(func)
-    for i in pl.seq.range(i1,i2) do
-        for j in pl.seq.range(j1,j2) do
-            M[i][j] = func(M[i][j])
-        end
+function pl.tex.deccmd(cs, def, overwrite) -- declare a definition, placeholder throws an error if it used but not set!
+    overwrite = pl.hasval(overwrite)
+    local decfun
+    if overwrite then decfun = pl.tex.defcmd else decfun = pl.tex.newcmd end
+    if def == nil then
+        decfun(cs, pkgerror('penlight', cs..' was declared and used in document, but never set'))
+    else
+        decfun(cs, def)
     end
-   return M
 end
 
-function pl.array2d.map_columns(func, M, j1, j2) -- map function to columns of matrix
-    j2 = j2 or j1
-    return map_slice2(func, M, 1, j1, -1, j2)
-end
 
-function pl.array2d.map_rows(func, M, i1, i2) -- map function to rows of matrix
-    i2 = i2 or i1
-    return map_slice2(func, M, i1, 1, i2, -1)
-end
+--
+-- -- todo add and improve this, options for args?
+--local function defcmd_nest(cs) -- for option if you'd like your commands under  a parent ex. \csparent{var}
+--    tex.print('\\gdef\\'..cs..'#1{\\csname '..var..'--#1--\\endcsname}')
+--end
+--
+--
+--local function defcmd(cs, val, nargs)
+--    if (nargs == nil) or (args == 0) then
+--        token.set_macro(cs, tostring(val), 'global')
+--    else
+--        local args = '#1'
+--        tex.print('\\gdef\\'..cs..args..'{'..val..'}')
+--        -- todo https://tex.stackexchange.com/questions/57551/create-a-capitalized-macro-token-using-csname
+--        --    \expandafter\gdef\csname Two\endcsname#1#2{1:#1, two:#2} --todo do it like this
+--    end
+--end
 
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
 
--- -- -- -- -- -- -- --
 
-function pl.array2d.sortOP(M, op, ele) -- sort a 2d array based on operator criteria, ele is column, ie sort on which element
-       M_new = {}
-        for row in pl.seq.sort(M, comp_2ele_func(op, ele)) do
-            M_new[#M_new+1] = row
-        end
-        return M_new
+
+-- when nesting commands, this makes it helpful to not worry about brackets
+pl.tex._NumBkts = 0
+--prt(opencmd('textbf')..opencmd('texttt')..'bold typwriter'..close_bkt_cnt())
+
+function pl.tex.opencmd(cmd)
+    return '\\'..cmd..add_bkt_cnt()
 end
 
-function pl.array2d.like(M1, v)
-    v = v or 0
-    r, c = pl.array2d.size(M1)
-    return pl.array2d.new(r,c,v)
+function pl.tex.reset_bkt_cnt(n)
+     n = n or 0
+    _NumBkts = n
 end
 
-function pl.array2d.from_table(t) -- turns a labelled table to a 2d, label-free array
-    t_new = {}
-    for k, v in pairs(t) do
-        if type(v) == 'table' then
-            t_new_row = {k}
-            for _, v_ in ipairs(v) do
-                 t_new_row[#t_new_row+1] =  v_
-            end
-            t_new[#t_new+1] = t_new_row
-        else
-            t_new[#t_new+1] = {k, v}
-        end
-    end
-    return t_new
+function pl.tex.add_bkt_cnt(n)
+    -- add open bracket n times, returns brackets
+     n = n or 1
+    _NumBkts = _NumBkts + n
+    return ('{'):rep(n)
 end
 
-function pl.array2d.toTeX(M, EL) --puts & between columns, can choose to end line with \\ if EL is true (end-line)
-    EL = EL or false
-    if EL then EL = '\\\\' else EL = '' end
-    return pl.array2d.reduce2(_1..EL.._2, _1..'&'.._2, M)..EL
+function pl.tex.close_bkt_cnt(n)
+    n = n or _NumBkts
+    local s = ('}'):rep(n)
+    _NumBkts = _NumBkts - n
+    return s
 end
 
--- -- -- -- -- -- --
 
 
 
 
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
 
--- -- -- -- -- -- -- -- -- -- -- --  functions below extend the operator module
 
-function pl.operator.strgt(a,b) return tostring(a) > tostring(b) end
-function pl.operator.strlt(a,b) return tostring(a) < tostring(b) end
 
 
 
 
+-- -- -- -- math stuff
+function math.mod(a, b) -- math modulo, return remainder only
+    return a - (math.floor(a/b)*b)
+end
+
+function math.mod2(a) -- math modulo 2
+    return math.mod(a,2)
+end
+
+
+
 -- -- -- -- string stuff
 local lpeg = require"lpeg"
 local P, R, S, V = lpeg.P, lpeg.R, lpeg.S, lpeg.V
@@ -184,14 +276,13 @@
     }
 
 
-local mt = getmetatable("") -- register functions with str
+local str_mt = getmetatable("") -- register functions with str
 
-
-function mt.__index.gnum(s)
+function str_mt.__index.gnum(s)
     return number:match(s)
 end
 
-function mt.__index.gextract(s, pat) --extract a pattern from string, returns both
+function str_mt.__index.gextract(s, pat) --extract a pattern from string, returns both
     local s_extr = ''
     local s_rem = s
     for e in s:gmatch(pat) do
@@ -201,7 +292,7 @@
     return s_extr, s_rem
 end
 
-function mt.__index.gfirst(s, t) -- get the first pattern found from a table of pattern
+function str_mt.__index.gfirst(s, t) -- get the first pattern found from a table of pattern
     for _, pat in pairs(t) do
         if string.find(s, pat) then
             return pat
@@ -209,7 +300,7 @@
     end
 end
 
-function mt.__index.appif(S, W, B, O) --append W ord to S tring if B oolean true, otherwise O ther
+function str_mt.__index.appif(S, W, B, O) --append W ord to S tring if B oolean true, otherwise O ther
     --append Word to String
     if B then --if b is true
         S = S .. W
@@ -221,30 +312,28 @@
 end
 
 
-
--- -- -- -- -- math stuffs
-
-function mod(a, b) -- math modulo, return remainder only
-    return a - (math.floor(a/b)*b)
+ function str_mt.__index.containsany(s, exp)
+    if type(exp) ~= 'table' then exp = {exp} end
+    for _, e in ipairs(exp) do
+        if s:find(e) then return true end
+    end
+    return false
 end
 
-function mod2(a) -- math modulo 2
-    return mod(a,2)
+function str_mt.__index.containsanycase(s, exp)
+    if type(exp) ~= 'table' then exp = {exp} end
+    for _, e in ipairs(exp) do
+        if s:lower():find(e:lower()) then return true end
+    end
+    return false
 end
 
-function hasval(x)  -- if something has value
-    if (type(x) == 'function') then
-        return true
-    elseif (x == nil) or (x == false) or (x == 0) or (x == '') then
-        return false
-    elseif (type(x) ~= 'boolean') and (type(x) ~= 'number') and (type(x) ~= 'string') then
-        if #x == 0 then
-            return false
-        else
-            return true
-        end
+function str_mt.__index.totable(str)
+    local t = {}
+    for i = 1, #str do
+        t[i] = str:sub(i, i)
     end
-    return true
+    return t
 end
 
 
@@ -251,125 +340,366 @@
 
 
 
+-- -- -- -- function stuff
 
+function pl.clone_function(fn)
+  local dumped = string.dump(fn)
+  local cloned = loadstring(dumped)
+  local i = 1
+  while true do
+    local name = debug.getupvalue(fn, i)
+    if not name then
+      break
+    end
+    debug.upvaluejoin(cloned, i, fn, i)
+    i = i + 1
+  end
+  return cloned
+end
 
 
 
 
--- Some simple and helpful LaTeX functions
--- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
--- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
--- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- -- -- -- -- -- -- -- -- -- -- --  functions below extend the operator module
 
---Generic LuaLaTeX utilities for print commands or environments
--- http://lua-users.org/wiki/SplitJoin -- todo read me!!
-__SKIP_TEX__ = __SKIP_TEX__ or false --if declared true before here, it will use regular print functions
---                                       (for troubleshooting with texlua)
+function pl.operator.strgt(a,b) return tostring(a) > tostring(b) end
+function pl.operator.strlt(a,b) return tostring(a) < tostring(b) end
 
--- xparse defaults
-_xTrue = '\\BooleanTrue '
-_xFalse = '\\BooleanFalse '
-_xNoValue = '-NoValue-'
 
 
-if not __SKIP_TEX__ then
-    prt = tex.sprint     --can print lists, but will NOT put new line between them
-    prtn = tex.print      --can print lists and will put new line. C-function not in Lua. think P rint R return
-    wrt = texio.write
-    wrtn = texio.write_nl
-else
-    prt = io.write
-    prtn = print --print with new line
-    wrt = io.write
-    wrtn = io.write_nl
+-- -- -- --  functions below are helpers for arrays and 2d
+
+local function compare_elements(a, b, op, ele)
+    op = op or pl.oper.gt
+    ele = ele or 1
+    return op(a[ele], b[ele])
 end
 
-function prtl(str) -- prints a literal string to latex, adds new line between them
-    for line in str:gmatch"[^\n]*" do  -- gets all characters up to a new line
-        prtn(line)
-    end
+local function comp_2ele_func(op, ele) -- make a 2 element comparison function,
+    --sort with function on element nnum
+    return bind(compare_elements, _1, _2, op, ele)
 end
 
 
 
-_NumBkts = 0
-function reset_bkt_cnt(n)
-     n = n or 0
-    _NumBkts = n
-end
 
-function add_bkt_cnt(n)
-    -- add open bracket n times, returns brackets
-     n = n or 1
-    _NumBkts = _NumBkts + n
-    return ('{'):rep(n)
+
+-- table stuff below
+
+
+function pl.tablex.map_slice(func, T, j1, j2)
+    if type(j1) == 'string' then
+        return pl.array2d.map_slice(func, {T}, ','..j1)[1]
+    else
+        return pl.array2d.map_slice(func, {T}, 1, j1, 1, j2)[1]
+    end
 end
 
-function close_bkt_cnt()
-    local s = ('}'):rep(_NumBkts)
-    reset_bkt_cnt()
-    return s
+pl.array2d.map_slice1 = pl.tablex.map_slice
+
+
+-- todo option for multiple filters with AND logic, like the filter files??
+function pl.tablex.filterstr(t, exp, case)
+    -- case = case sensitive
+    case = hasval(case)
+    -- apply lua patterns to a table to filter iter
+    -- str or table of str's can be passed, OR logic is used if table is passed
+    if case then
+        return pl.tablex.filter(t, bind(string.containsany,_1,exp))
+    else
+        return pl.tablex.filter(t, bind(string.containsanycase,_1,exp))
+    end
 end
 
 
+function pl.utils.filterfiles(...)
+    -- f1 is a series of filtering patterns, or condition
+    -- f2 is a series of filtering patters, or condition
+    -- (f1_a or f2_...) and (f2 .. ) must match
+    local args = table.pack(...)
+    -- todo -- check where boolean is for recursive or not, set starting argument
+    -- this could allow one to omit dir
+    -- todo if no boolean at all, assume dir = '.' and r = false
+    -- if boolean given, assume dir = '.'
+    local nstart = 3
+    local r = args[2]
+    local dir = args[1]
+    if type(args[1]) == 'boolean' then
+        dir = '.'
+        r =  args[1]
+        nstart = 2
+    elseif type(args[2]) ~= 'boolean' then
+        dir = '.'
+        r =  false
+        nstart = 1
+    end
 
+    local files
+    if r then  files = pl.dir.getallfiles(dir)
+    else files = pl.dir.getfiles(dir)
+    end
+    for i=nstart,args.n do
+        files = pl.tablex.filter(files, pl.func.compose(bind(string.containsanycase,_1, args[i]), pl.path.basename))
+    end
+    return  files
+end
 
 
 
 
+-- -- -- -- -- -- -- --  functions below extend the array2d module
 
---definition helpers -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
--- todo add and improve this
 
-local function defcmd_nest(cs) -- for option if you'd like your commands under  a parent ex. \csparent{var}
-    tex.print('\\gdef\\'..cs..'#1{\\csname '..var..'--#1--\\endcsname}')
+function pl.array2d.map_slice(func, M, i1, j1, i2, j2) -- map a function to a slice of a Matrix
+    func = pl.utils.function_arg(1, func)
+    for i,j in pl.array2d.iter(M, true, i1, j1, i2, j2) do
+        M[i][j] = func(M[i][j])
+    end
+   return M
 end
 
+pl.array2d.map_slice2 = pl.array2d.map_slice
 
-local function defcmd(cs, val, nargs)
-    if (nargs == nil) or (args == 0) then
-        token.set_macro(cs, tostring(val), 'global')
+function pl.array2d.map_cols(func, M, j1, j2) -- map function to columns of matrix
+    if type(j1) == 'string' then
+        return pl.array2d.map_slice(func, M, ','..j1)
     else
-        local args = '#1'
-        tex.print('\\gdef\\'..cs..args..'{'..val..'}')
-        -- todo https://tex.stackexchange.com/questions/57551/create-a-capitalized-macro-token-using-csname
-        --    \expandafter\gdef\csname Two\endcsname#1#2{1:#1, two:#2} --todo do it like this
+        j2 = j2 or -1
+        return pl.array2d.map_slice(func, M, 1, j1, -1, j2)
     end
 end
 
+pl.array2d.map_columns = pl.array2d.map_cols
 
-local function prvcmd(cs, val) -- provide command via lua
-   if token.is_defined(cs) then
-        tex.print('\\PackageWarning{YAMLvars}{Variable '..cs..' already defined, could not declare}{}')
+function pl.array2d.map_rows(func, M, i1, i2) -- map function to rows of matrix
+    if type(i1) == 'string' then
+        return pl.array2d.map_slice(func, M, i1)
     else
-        defcmd(cs, val)
+        i2 = i2 or -1
+        return pl.array2d.map_slice(func, M, i1, 1, i2, -1)
     end
 end
 
 
-local function newcmd(cs, val) -- provide command via lua
-   if token.is_defined(cs) then
-        tex.print('\\PackageError{luadefs}{Command '..cs..' already defined}{}')
-    else
-        defcmd(cs, val)
+-- -- -- -- -- -- -- --
+
+function pl.array2d.sortOP(M, op, ele) -- sort a 2d array based on operator criteria, ele is column, ie sort on which element
+       M_new = {}
+        for row in pl.seq.sort(M, comp_2ele_func(op, ele)) do
+            M_new[#M_new+1] = row
+        end
+        return M_new
+end
+
+function pl.array2d.like(M1, v)
+    v = v or 0
+    r, c = pl.array2d.size(M1)
+    return pl.array2d.new(r,c,v)
+end
+
+function pl.array2d.from_table(t) -- turns a labelled table to a 2d, label-free array
+    t_new = {}
+    for k, v in pairs(t) do
+        if type(v) == 'table' then
+            t_new_row = {k}
+            for _, v_ in ipairs(v) do
+                 t_new_row[#t_new_row+1] =  v_
+            end
+            t_new[#t_new+1] = t_new_row
+        else
+            t_new[#t_new+1] = {k, v}
+        end
     end
+    return t_new
 end
 
-local function renewcmd(cs, val) -- provide command via lua
-   if token.is_defined(cs) then
-        defcmd(cs, val)
+function pl.array2d.toTeX(M, EL) --puts & between columns, can choose to end line with \\ if EL is true (end-line)
+    EL = EL or false
+    if EL then EL = '\\\\' else EL = '' end
+    return pl.array2d.reduce2(_1..EL.._2, _1..'&'.._2, M)..EL
+end
+
+
+local function parse_numpy1d(i1, i2, iS)
+    i1 = tonumber(i1)
+    i2 = tonumber(i2)
+    if iS == ':' then
+        if i1 == nil then i1 = 1 end
+        if i2 == nil then i2 = -1 end
     else
-        tex.print('\\PackageError{luadefs}{Command '..cs..' already defined}{}')
+        if i1 == nil then
+            i1 = 1
+            i2 = -1
+        else
+            i2 = i1
+        end
     end
+    return i1, i2
 end
 
-local function deccmd(cs, def)
-    if def == nil then
-        prvcmd(cs, '\\PackageError{luadefs}{Command "'..cs..'" was declared and used but, not set}{}')
-    else
-        prvcmd(cs, def)
+function pl.array2d.parse_numpy2d_str(s)
+    s = s:gsub('%s+', '')
+    _, _, i1, iS, i2, j1, jS, j2 = string.find(s, "(%-?%d*)(:?)(%-?%d*),?(%-?%d*)(:?)(%-?%d*)")
+    i1, i2 = parse_numpy1d(i1, i2, iS)
+    j1, j2 = parse_numpy1d(j1, j2, jS)
+    return i1, j1, i2, j2
+end
+
+
+local _parse_range = pl.clone_function(pl.array2d.parse_range)
+
+function pl.array2d.parse_range(s) -- edit parse range to do numpy string if no letter passed
+    pl.utils.assert_arg(1,s,'string')
+    if not s:find'%a' then
+        return pl.array2d.parse_numpy2d_str(s)
     end
+    return _parse_range(s)
 end
 
 
--- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+
+
+
+
+if not __PL_NO_GLOBALS__ then
+    -- iterators
+    kpairs = pl.utils.kpairs
+    npairs = pl.utils.npairs
+    --enum = utils.enum
+
+    for k,v in pairs(pl.tablex) do  -- extend the table table to contain tablex functions
+        _G['table'][k] = v
+    end
+
+    hasval = pl.hasval
+    COMP = pl.COMP
+
+    -- shortcuts
+-- http://stevedonovan.github.io/Penlight/api/libraries/pl.utils.html
+    pl.writefile = pl.utils.writefile
+    pl.readfile = pl.utils.readfile
+    pl.readlines = pl.utils.readfile
+    pl.filterfiles = pl.utils.filterfiles
+
+    pl.a2 = pl.array2d
+    pl.tbl = pl.tablex
+
+
+    for k,v in pairs(pl.tex) do  -- make tex functions global
+        _G[k] = v
+    end
+
+    --_xTrue = pl.tex._xTrue
+    --_xFalse = pl.tex._xFalse
+    --_xNoValue = pl.tex._xNoValue
+    --
+    --prt = pl.tex.prt
+    --prtn = pl.tex.prtn
+    --wrt = pl.tex.wrt
+    --wrtn = pl.tex.wrtn
+    --
+    --prtl = pl.tex.prtl
+    --prtt = pl.tex.prtt
+    --
+    --help_wrt = pl.tex.help_wrt
+    --prt_array2d = pl.tex.prt_array2d
+    --
+    --pkgwarn = pl.tex.pkgwarn
+    --pkgerror = pl.tex.pkgerror
+    --
+    --defcmd = pl.tex.defcmd
+    --prvcmd = pl.tex.prvcmd
+    --newcmd = pl.tex.newcmd
+    --renewcmd = pl.tex.renewcmd
+    --deccmd = pl.tex.deccmd
+    --
+    --_NumBkts = pl.tex._NumBkts
+    --opencmd = pl.tex.opencmd
+    --reset_bkt_cnt = pl.tex.reset_bkt_cnt
+    --add_bkt_cnt = pl.tex.add_bkt_cnt
+    --close_bkt_cnt = pl.tex.close_bkt_cnt
+
+end
+
+
+
+
+
+-- graveyard
+
+
+-- luakeys parses individual keys as ipairs, this changes the list to a pure map
+--function pl.luakeystomap(t)
+--    local t_new = {}
+--    for k, v in pairs(t) do
+--        if type(k) == 'number' then
+--            t_new[v] = true
+--        else
+--            t_new[k] = v
+--        end
+--    end
+--    return t_new
+--end
+--if luakeys then -- if luakeys is already loaded
+--    function luakeys.parseN(s, ...)
+--        local t = luakeys.parse(s,...)
+--        t = pl.luakeystomap(t)
+--        return t
+--    end
+--end
+-- might not be needed
+
+
+    --local func = check_func(func)
+--local function check_func(func)  -- check if a function is a PE, if so, make it a function
+--    if type(func) ~= 'function' then
+--        return I(func)
+--    end
+--    return func
+--end
+
+-- -- -- -- -- -- --
+-- -- -- --  functions below extend the array2d module
+
+
+--function pl.array2d.map_slice1(func, L, i1, i2) -- map a function to a slice of an array, can use PlcExpr
+--    i2 = i2 or i1
+--    local len = #L
+--    i1 = check_index(i1, len)
+--    i2 = check_index(i2, len)
+--    func = check_func(func)
+--    for i in pl.seq.range(i1,i2) do
+--            L[i] = func(L[i])
+--        end
+--   return L
+--end
+
+    -- used this below when iter was not working..
+    --i1, j1, i2, j2 = check_slice(M, i1, j1, i2, j2)
+        --for i in pl.seq.range(i1,i2) do
+    --    for j in pl.seq.range(j1,j2) do
+        --end
+    -- penlight may have fixed this
+--local function check_index(ij, rc) -- converts array index to positive value if negative
+--    if type(ij) ~= 'number' then
+--        return 1
+--    else
+--        if ij < 0 then
+--            ij = rc + ij + 1
+--        elseif ij > rc then
+--            ij = rc
+--        elseif ij == 0 then
+--            ij = 1
+--        end
+--        return ij
+--    end
+--end
+--local function check_slice(M, i1, j1, i2, j2) -- ensure a slice is valid; i.e. all positive numbers
+--    r, c = pl.array2d.size(M)
+--    i1 = check_index(i1 or 1, r)
+--    i2 = check_index(i2 or r, r)
+--    j1 = check_index(j1 or 1, c)
+--    j2 = check_index(j2 or c, c)
+--    return i1, j1, i2, j2
+--end
+



More information about the tex-live-commits mailing list.