Lua: marshal Attr values as userdata

- Adds a new `pandoc.AttributeList()` constructor, which creates the
  associative attribute list that is used as the third component of
  `Attr` values. Values of this type can often be passed to constructors
  instead of `Attr` values.

- `AttributeList` values can no longer be indexed numerically.
This commit is contained in:
Albert Krewinkel 2021-10-20 21:40:07 +02:00 committed by John MacFarlane
parent e4287e6c95
commit 8523bb01b2
8 changed files with 272 additions and 188 deletions

View file

@ -281,33 +281,12 @@ local function ensureDefinitionPairs (pair)
return {inlines, blocks}
end
--- Split a string into it's words, using whitespace as separators.
local function words (str)
local ws = {}
for w in str:gmatch("([^%s]+)") do ws[#ws + 1] = w end
return ws
end
--- Try hard to turn the arguments into an Attr object.
local function ensureAttr(attr)
if type(attr) == 'table' then
if #attr > 0 then return M.Attr(table.unpack(attr)) end
-- assume HTML-like key-value pairs
local ident = attr.id or ''
local classes = words(attr.class or '')
local attributes = attr
attributes.id = nil
attributes.class = nil
return M.Attr(ident, classes, attributes)
elseif attr == nil then
return M.Attr()
elseif type(attr) == 'string' then
-- treat argument as ID
return M.Attr(attr)
if type(attr) == 'userdata' then
return attr
end
-- print(arg, ...)
error('Could not convert to Attr')
return M.Attr(attr)
end
------------------------------------------------------------------------
@ -831,134 +810,6 @@ M.Underline = M.Inline:create_constructor(
-- Element components
-- @section components
--- Check if the first element of a pair matches the given value.
-- @param x key value to be checked
-- @return function returning true iff first element of its argument matches x
-- @local
local function assoc_key_equals (x)
return function (y) return y[1] == x end
end
--- Lookup a value in an associative list
-- @function lookup
-- @local
-- @tparam {{key, value},...} alist associative list
-- @param key key for which the associated value is to be looked up
local function lookup(alist, key)
return (List.find_if(alist, assoc_key_equals(key)) or {})[2]
end
--- Return an iterator which returns key-value pairs of an associative list.
-- @function apairs
-- @local
-- @tparam {{key, value},...} alist associative list
local apairs = function (alist)
local i = 1
local cur
function nxt ()
cur = rawget(alist, i)
if cur then
i = i + 1
return cur[1], cur[2]
end
return nil
end
return nxt, nil, nil
end
--- AttributeList, a metatable to allow table-like access to attribute lists
-- represented by associative lists.
-- @local
local AttributeList = {
__index = function (t, k)
if type(k) == "number" then
return rawget(t, k)
else
return lookup(t, k)
end
end,
__newindex = function (t, k, v)
local cur, idx = List.find_if(t, assoc_key_equals(k))
if v == nil and not cur then
-- deleted key does not exists in list
return
elseif v == nil then
table.remove(t, idx)
elseif cur then
cur[2] = v
elseif type(k) == "number" then
rawset(t, k, v)
else
rawset(t, #t + 1, {k, v})
end
end,
__pairs = apairs
}
--- Convert a table to an associative list. The order of key-value pairs in the
-- alist is undefined. The table should either contain no numeric keys or
-- already be an associative list.
-- @local
-- @tparam table tbl associative list or table without numeric keys.
-- @treturn table associative list
local to_alist = function (tbl)
if #tbl ~= 0 or next(tbl) == nil then
-- probably already an alist
return tbl
end
local alist = {}
local i = 1
for k, v in pairs(tbl) do
alist[i] = {k, v}
i = i + 1
end
return alist
end
-- Attr
--- Create a new set of attributes (Attr).
-- @function Attr
-- @tparam[opt] string identifier element identifier
-- @tparam[opt] {string,...} classes element classes
-- @tparam[opt] table attributes table containing string keys and values
-- @return element attributes
M.Attr = AstElement:make_subtype'Attr'
function M.Attr:new (identifier, classes, attributes)
identifier = identifier or ''
classes = ensureList(classes or {})
attributes = setmetatable(to_alist(attributes or {}), AttributeList)
return setmetatable({identifier, classes, attributes}, self.behavior)
end
M.Attr.behavior.clone = M.types.clone.Attr
M.Attr.behavior.tag = 'Attr'
M.Attr.behavior._field_names = {identifier = 1, classes = 2, attributes = 3}
M.Attr.behavior.__eq = utils.equals
M.Attr.behavior.__index = function(t, k)
return (k == 't' and t.tag) or
rawget(t, getmetatable(t)._field_names[k]) or
getmetatable(t)[k]
end
M.Attr.behavior.__newindex = function(t, k, v)
if k == 'attributes' then
rawset(t, 3, setmetatable(to_alist(v or {}), AttributeList))
elseif getmetatable(t)._field_names[k] then
rawset(t, getmetatable(t)._field_names[k], v)
else
rawset(t, k, v)
end
end
M.Attr.behavior.__pairs = function(t)
local field_names = M.Attr.behavior._field_names
local fields = {}
for name, i in pairs(field_names) do
fields[i] = name
end
return make_next_function(fields), t, nil
end
-- Monkey-patch setters for `attr` fields to be more forgiving in the input that
-- results in a valid Attr value.
function augment_attr_setter (setters)

View file

@ -776,6 +776,7 @@ library
Text.Pandoc.Lua.Init,
Text.Pandoc.Lua.Marshaling,
Text.Pandoc.Lua.Marshaling.AST,
Text.Pandoc.Lua.Marshaling.Attr,
Text.Pandoc.Lua.Marshaling.CommonState,
Text.Pandoc.Lua.Marshaling.Context,
Text.Pandoc.Lua.Marshaling.List,

View file

@ -89,7 +89,6 @@ putConstructorsInRegistry = liftPandocLua $ do
constrsToReg $ Pandoc.Meta mempty
constrsToReg $ Pandoc.MetaList mempty
constrsToReg $ Pandoc.Citation mempty mempty mempty Pandoc.AuthorInText 0 0
putInReg "Attr" -- used for Attr type alias
putInReg "ListAttributes" -- used for ListAttributes type alias
putInReg "List" -- pandoc.List
putInReg "SimpleTable" -- helper for backward-compatible table handling

View file

@ -1,4 +1,5 @@
{-# OPTIONS_GHC -fno-warn-orphans #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
@ -40,6 +41,7 @@ import Control.Monad ((<$!>), (>=>))
import HsLua hiding (Operation (Div))
import Text.Pandoc.Definition
import Text.Pandoc.Lua.Util (pushViaConstr', pushViaConstructor)
import Text.Pandoc.Lua.Marshaling.Attr (peekAttr, pushAttr)
import Text.Pandoc.Lua.Marshaling.List (pushPandocList)
import qualified HsLua as Lua
@ -413,19 +415,6 @@ peekInline = retrieving "Inline" . \idx -> do
"Superscript"-> mkBlock Superscript peekInlines
Name tag -> Lua.failPeek ("Unknown inline type: " <> tag)
pushAttr :: forall e. LuaError e => Attr -> LuaE e ()
pushAttr (id', classes, kv) = pushViaConstr' @e "Attr"
[ pushText id'
, pushList pushText classes
, pushList (pushPair pushText pushText) kv
]
peekAttr :: LuaError e => Peeker e Attr
peekAttr = retrieving "Attr" . peekTriple
peekText
(peekList peekText)
(peekList (peekPair peekText peekText))
pushListAttributes :: forall e. LuaError e => ListAttributes -> LuaE e ()
pushListAttributes (start, style, delimiter) =
pushViaConstr' "ListAttributes"
@ -450,3 +439,6 @@ instance Peekable Meta where
instance Peekable Pandoc where
peek = forcePeek . peekPandoc
instance {-# OVERLAPPING #-} Peekable Attr where
peek = forcePeek . peekAttr

View file

@ -0,0 +1,225 @@
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE TypeApplications #-}
{- |
Module : Text.Pandoc.Lua.Marshaling.Attr
Copyright : © 2012-2021 John MacFarlane
© 2017-2021 Albert Krewinkel
License : GNU GPL, version 2 or above
Maintainer : Albert Krewinkel <tarleb+pandoc@moltkeplatz.de>
Stability : alpha
Marshaling/unmarshaling instances for document AST elements.
-}
module Text.Pandoc.Lua.Marshaling.Attr
( typeAttr
, peekAttr
, pushAttr
, mkAttr
, mkAttributeList
) where
import Control.Applicative ((<|>), optional)
import Control.Monad ((<$!>))
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import HsLua
import HsLua.Marshalling.Peekers (peekIndexRaw)
import Safe (atMay)
import Text.Pandoc.Definition (Attr, nullAttr)
import Text.Pandoc.Lua.Marshaling.List (pushPandocList)
import qualified Data.Text as T
typeAttr :: LuaError e => DocumentedType e Attr
typeAttr = deftype "Attr"
[ operation Eq $ lambda
### liftPure2 (==)
<#> parameter peekAttr "a1" "Attr" ""
<#> parameter peekAttr "a2" "Attr" ""
=#> functionResult pushBool "boolean" "whether the two are equal"
]
[ property "identifier" "element identifier"
(pushText, \(ident,_,_) -> ident)
(peekText, \(_,cls,kv) -> (,cls,kv))
, property "classes" "element classes"
(pushPandocList pushText, \(_,classes,_) -> classes)
(peekList peekText, \(ident,_,kv) -> (ident,,kv))
, property "attributes" "various element attributes"
(pushAttribs, \(_,_,attribs) -> attribs)
(peekAttribs, \(ident,cls,_) -> (ident,cls,))
, method $ defun "clone"
### return
<#> parameter peekAttr "attr" "Attr" ""
=#> functionResult pushAttr "Attr" "new Attr element"
]
pushAttr :: LuaError e => Pusher e Attr
pushAttr = pushUD typeAttr
peekAttribs :: LuaError e => Peeker e [(Text,Text)]
peekAttribs idx = liftLua (ltype idx) >>= \case
TypeUserdata -> peekUD typeAttributeList idx
TypeTable -> liftLua (rawlen idx) >>= \case
0 -> peekKeyValuePairs peekText peekText idx
_ -> peekList (peekPair peekText peekText) idx
_ -> fail "unsupported type"
pushAttribs :: LuaError e => Pusher e [(Text, Text)]
pushAttribs = pushUD typeAttributeList
typeAttributeList :: LuaError e => DocumentedType e [(Text, Text)]
typeAttributeList = deftype "AttributeList"
[ operation Eq $ lambda
### liftPure2 (==)
<#> parameter peekAttribs "a1" "AttributeList" ""
<#> parameter peekAttribs "a2" "AttributeList" ""
=#> functionResult pushBool "boolean" "whether the two are equal"
, operation Index $ lambda
### liftPure2 lookupKey
<#> udparam typeAttributeList "t" "attributes list"
<#> parameter peekKey "string|integer" "key" "lookup key"
=#> functionResult (maybe pushnil pushAttribute) "string|table"
"attribute value"
, operation Newindex $ lambda
### setKey
<#> udparam typeAttributeList "t" "attributes list"
<#> parameter peekKey "string|integer" "key" "lookup key"
<#> optionalParameter peekAttribute "string|nil" "value" "new value"
=#> []
, operation Len $ lambda
### liftPure length
<#> udparam typeAttributeList "t" "attributes list"
=#> functionResult pushIntegral "integer" "number of attributes in list"
, operation Pairs $ lambda
### pushIterator (\(k, v) -> 2 <$ pushText k <* pushText v)
<#> udparam typeAttributeList "t" "attributes list"
=?> "iterator triple"
, operation Tostring $ lambda
### liftPure show
<#> udparam typeAttributeList "t" "attributes list"
=#> functionResult pushString "string" ""
]
[]
data Key = StringKey Text | IntKey Int
peekKey :: LuaError e => Peeker e (Maybe Key)
peekKey idx = liftLua (ltype idx) >>= \case
TypeNumber -> Just . IntKey <$!> peekIntegral idx
TypeString -> Just . StringKey <$!> peekText idx
_ -> return Nothing
data Attribute
= AttributePair (Text, Text)
| AttributeValue Text
pushAttribute :: LuaError e => Pusher e Attribute
pushAttribute = \case
(AttributePair kv) -> pushPair pushText pushText kv
(AttributeValue v) -> pushText v
-- | Retrieve an 'Attribute'.
peekAttribute :: LuaError e => Peeker e Attribute
peekAttribute idx = (AttributeValue <$!> peekText idx)
<|> (AttributePair <$!> peekPair peekText peekText idx)
lookupKey :: [(Text,Text)] -> Maybe Key -> Maybe Attribute
lookupKey !kvs = \case
Just (StringKey str) -> AttributeValue <$> lookup str kvs
Just (IntKey n) -> AttributePair <$!> atMay kvs (n - 1)
Nothing -> Nothing
setKey :: forall e. LuaError e
=> [(Text, Text)] -> Maybe Key -> Maybe Attribute
-> LuaE e ()
setKey kvs mbKey mbValue = case mbKey of
Just (StringKey str) ->
case break ((== str) . fst) kvs of
(prefix, _:suffix) -> case mbValue of
Nothing -> setNew $ prefix ++ suffix
Just (AttributeValue value) -> setNew $ prefix ++ (str, value):suffix
_ -> failLua "invalid attribute value"
_ -> case mbValue of
Nothing -> return ()
Just (AttributeValue value) -> setNew (kvs ++ [(str, value)])
_ -> failLua "invalid attribute value"
Just (IntKey idx) ->
case splitAt (idx - 1) kvs of
(prefix, (k,_):suffix) -> setNew $ case mbValue of
Nothing -> prefix ++ suffix
Just (AttributePair kv) -> prefix ++ kv : suffix
Just (AttributeValue v) -> prefix ++ (k, v) : suffix
(prefix, []) -> case mbValue of
Nothing -> setNew prefix
Just (AttributePair kv) -> setNew $ prefix ++ [kv]
_ -> failLua $ "trying to set an attribute key-value pair, "
++ "but got a single string instead."
_ -> failLua "invalid attribute key"
where
setNew :: [(Text, Text)] -> LuaE e ()
setNew new =
putuserdata (nthBottom 1) (udName @e typeAttributeList) new >>= \case
True -> return ()
False -> failLua "failed to modify attributes list"
peekAttr :: LuaError e => Peeker e Attr
peekAttr idx = retrieving "Attr" $ liftLua (ltype idx) >>= \case
TypeString -> (,[],[]) <$!> peekText idx -- treat string as ID
TypeUserdata -> peekUD typeAttr idx
TypeTable -> peekAttrTable idx
x -> liftLua . failLua $ "Cannot get Attr from " ++ show x
-- | Helper function which gets an Attr from a Lua table.
peekAttrTable :: LuaError e => Peeker e Attr
peekAttrTable idx = do
len' <- liftLua $ rawlen idx
let peekClasses = peekList peekText
if len' > 0
then do
ident <- peekIndexRaw 1 peekText idx
classes <- fromMaybe [] <$!> optional (peekIndexRaw 2 peekClasses idx)
attribs <- fromMaybe [] <$!> optional (peekIndexRaw 3 peekAttribs idx)
return $ ident `seq` classes `seq` attribs `seq`
(ident, classes, attribs)
else retrieving "HTML-like attributes" $ do
kvs <- peekKeyValuePairs peekText peekText idx
let ident = fromMaybe "" $ lookup "id" kvs
let classes = maybe [] T.words $ lookup "class" kvs
let attribs = filter ((`notElem` ["id", "class"]) . fst) kvs
return $ ident `seq` classes `seq` attribs `seq`
(ident, classes, attribs)
mkAttr :: LuaError e => LuaE e NumResults
mkAttr = do
attr <- ltype (nthBottom 1) >>= \case
TypeString -> forcePeek $ do
mident <- optional (peekText (nthBottom 1))
mclass <- optional (peekList peekText (nthBottom 2))
mattribs <- optional (peekAttribs (nthBottom 3))
return (fromMaybe "" mident, fromMaybe [] mclass, fromMaybe [] mattribs)
TypeTable -> forcePeek $ peekAttrTable (nthBottom 1)
TypeUserdata -> forcePeek $ peekUD typeAttr (nthBottom 1) <|> do
attrList <- peekUD typeAttributeList (nthBottom 1)
return ("", [], attrList)
TypeNil -> pure nullAttr
TypeNone -> pure nullAttr
x -> failLua $ "Cannot create Attr from " ++ show x
pushAttr attr
return 1
mkAttributeList :: LuaError e => LuaE e NumResults
mkAttributeList = do
attribs <- forcePeek $ peekAttribs (nthBottom 1)
pushUD typeAttributeList attribs
return 1

View file

@ -29,6 +29,7 @@ import Text.Pandoc.Lua.Filter (SingletonsList (..), walkInlines,
walkInlineLists, walkBlocks, walkBlockLists)
import Text.Pandoc.Lua.Marshaling ()
import Text.Pandoc.Lua.Marshaling.AST
import Text.Pandoc.Lua.Marshaling.Attr (mkAttr, mkAttributeList)
import Text.Pandoc.Lua.Marshaling.List (List (..))
import Text.Pandoc.Lua.PandocLua (PandocLua, addFunction, liftPandocLua,
loadDefaultModule)
@ -54,6 +55,8 @@ pushModule = do
addFunction "walk_inline" (walkElement peekInline pushInline)
-- Constructors
addFunction "Pandoc" mkPandoc
addFunction "Attr" (liftPandocLua mkAttr)
addFunction "AttributeList" (liftPandocLua mkAttributeList)
return 1
walkElement :: (Walkable (SingletonsList Inline) a,

View file

@ -204,7 +204,7 @@ tests = map (localOption (QuickCheckTests 20))
[Para [Str "ignored"]])
Lua.getfield Lua.top "attr"
Lua.liftIO . assertEqual "no accessor" (("hi", ["moin"], []) :: Attr)
=<< Lua.peek Lua.top
=<< Lua.peek @Attr Lua.top
, testCase "module `pandoc.system` is present" . runLuaTest $ do
Lua.getglobal' "pandoc.system"

View file

@ -11,34 +11,32 @@ end
return {
group 'Attr' {
group 'Constructor' {
test('pandoc.Attr is a function', function ()
assert.are_equal(type(pandoc.Attr), 'function')
end),
test('returns null-Attr if no arguments are given', function ()
local attr = pandoc.Attr()
assert.are_equal(attr.identifier, '')
assert.are_same(attr.classes, {})
assert.are_same(attr.attributes, {})
assert.are_same(#attr.attributes, 0)
end),
test(
'accepts string-indexed table or list of pairs as attributes',
function ()
local attributes_list = pandoc.List:new {{'one', '1'}, {'two', '2'}}
local attr_from_list = pandoc.Attr('', {}, attributes_list:clone())
local attributes_list = {{'one', '1'}, {'two', '2'}}
local attr_from_list = pandoc.Attr('', {}, attributes_list)
assert.are_same(
pandoc.List:new(attr_from_list.attributes),
attributes_list
)
assert.are_equal(attr_from_list.attributes.one, '1')
assert.are_equal(attr_from_list.attributes.two, '2')
local attributes_table = {one = '1', two = '2'}
local attr_from_table = pandoc.Attr('', {}, attributes_table)
local assoc_list_from_table =
pandoc.List:new(attr_from_table.attributes)
-- won't work in general, but does in this special case
table.sort(assoc_list_from_table, function(x, y) return x[1]<y[1] end)
assert.are_same(
assoc_list_from_table,
attributes_list
assert.are_equal(
attr_from_table.attributes,
pandoc.AttributeList(attributes_table)
)
assert.are_equal(attr_from_table.attributes.one, '1')
assert.are_equal(attr_from_table.attributes.two, '2')
end
)
},
@ -53,27 +51,41 @@ return {
assert.are_same(attributes[1], {'a', '1'})
assert.are_same(attributes[2], {'b', '2'})
end),
test('allows replacing a pair', function ()
local attributes = pandoc.AttributeList{{'a', '1'}, {'b', '2'}}
attributes[1] = {'t','five'}
assert.are_same(attributes[1], {'t', 'five'})
assert.are_same(attributes[2], {'b', '2'})
end),
test('allows to remove a pair', function ()
local attributes = pandoc.AttributeList{{'a', '1'}, {'b', '2'}}
attributes[1] = nil
assert.are_equal(#attributes, 1)
end),
test('adds entries by field name', function ()
local attributes = pandoc.Attr('',{}, {{'c', '1'}, {'d', '2'}}).attributes
attributes.e = '3'
assert.are_same(
attributes,
-- checking the full AttributeList would "duplicate" entries
setmetatable(attributes, nil),
{{'c', '1'}, {'d', '2'}, {'e', '3'}}
pandoc.AttributeList{{'c', '1'}, {'d', '2'}, {'e', '3'}}
)
end),
test('deletes entries by field name', function ()
local attributes = pandoc.Attr('',{}, {a = '1', b = '2'}).attributes
attributes.a = nil
assert.is_nil(attributes.a)
local assoc_list = setmetatable(attributes, nil)
assert.are_same(assoc_list, {{'b', '2'}})
assert.are_same(attributes, pandoc.AttributeList{{'b', '2'}})
end),
test('remains unchanged if deleted key did not exist', function ()
local assoc_list = pandoc.List:new {{'alpha', 'x'}, {'beta', 'y'}}
local attributes = pandoc.Attr('', {}, assoc_list:clone()).attributes
local attributes = pandoc.Attr('', {}, assoc_list).attributes
attributes.a = nil
assert.are_same(pandoc.List:new(attributes), assoc_list)
local new_assoc_list = pandoc.List()
for k, v in pairs(attributes) do
new_assoc_list:insert({k, v})
end
assert.are_same(new_assoc_list, assoc_list)
end),
test('gives key-value pairs when iterated-over', function ()
local attributes = {width = '11', height = '22', name = 'test'}
@ -110,6 +122,7 @@ return {
}
local span = pandoc.Span 'test'
span.attr = html_attributes
span = span:clone() -- normalize
assert.are_equal(span.attr.identifier, 'the-id')
assert.are_equal(span.attr.classes[1], 'class1')
assert.are_equal(span.attr.classes[2], 'class2')