org-parser-0.1.0.0: src/Org/Parser/Elements.hs
-- | Parsers for Org elements.
module Org.Parser.Elements
( -- * General
elements
-- * Greater elements
, plainList
, greaterBlock
, drawer
, footnoteDef
, table
-- * Lesser elements
, clock
, exampleBlock
, fixedWidth
, srcBlock
, exportBlock
, latexEnvironment
, keyword
, horizontalRule
, commentLine
, commentBlock
) where
import Data.Text qualified as T
import Org.Builder qualified as B
import Org.Parser.Common
import Org.Parser.Definitions
import Org.Parser.MarkupContexts
import Org.Parser.Objects
import Relude.Extra hiding (elems, next)
import Replace.Megaparsec
import Prelude hiding (many, some)
-- * General
-- | Parse zero or more Org elements.
elements :: OrgParser OrgElements
elements = mconcat <$> many e
where
e = notFollowedBy eof >> elementIndented 0 False
elementsIndented :: Int -> OrgParser OrgElements
elementsIndented minI = mconcat <$> many e
where
e = do
notFollowedBy (try (blankline' *> blankline') <|> eof)
elementIndented minI False
{- | Each element parser must consume till the start of a line or EOF.
This is necessary for correct counting of list indentations.
-}
elementIndented ::
Int ->
Bool ->
OrgParser OrgElements
elementIndented minI paraEnd = try (goKws [])
where
goKws kws = do
notFollowedBy headingStart
i <- spacesOrTabs
blank kws <|> do
guard (i >= minI)
optional affiliatedKeyword >>= \case
Just akw -> goKws (akw : kws) <|> return (affToKws (akw : kws))
Nothing -> withIndentLevel i $ finalize kws
finalize kws = do
B.element' kws <$> nonParaElement <|> do
guard (not (paraEnd && null kws))
paraIndented minI kws
blank kws = do
blankline' $> affToKws kws
affToKws kws = mconcat (B.element . uncurry B.keyword <$> kws)
nonParaElement =
choice
[ clock
, commentLine
, exampleBlock
, srcBlock
, exportBlock
, commentBlock
, greaterBlock
, plainList
, latexEnvironment
, drawer
, fixedWidth
, keyword
, horizontalRule
, table
, footnoteDef
]
paraIndented :: Int -> [(Text, KeywordValue)] -> OrgParser OrgElements
paraIndented minI kws =
blankline' $> mempty <|> do
(inls, next) <- withContext_ skip end (plainMarkupContext standardSet)
return $ B.element' kws (B.para inls) <> next
where
skip = anySingle >> takeWhileP Nothing (/= '\n')
end :: OrgParser OrgElements
end =
(eof $> mempty) <|> try do
_ <- newline
lookAhead blankline' $> mempty
<|> elementIndented minI True
<|> lookAhead headingStart $> mempty
-- rest of line can't be blank, otherwise elementIndented would succeed
<|> lookAhead (try $ guard . (< minI) =<< spacesOrTabs) $> mempty
{-# INLINEABLE paraIndented #-}
-- traceWithPos :: String -> OrgParser ()
-- traceWithPos m = do
-- s <- getParserState
-- let
-- err :: ParseError Text Void = FancyError (stateOffset s) (one $ ErrorFail m)
-- bundle = ParseErrorBundle (err :| []) (statePosState s)
-- traceM $ errorBundlePretty bundle
-- * Greater elements
-- ** Lists
-- | Parse a plain list.
plainList :: OrgParser OrgElementData
plainList = try do
fstItem <- listItem
rest <- many itemIndented
let kind = listItemType fstItem
items = fstItem : rest
return $ B.list kind items
where
itemIndented = try do
notFollowedBy headingStart
i <- asks orgEnvIndentLevel
j <- spacesOrTabs
guard (j == i)
listItem
listItem :: OrgParser ListItem
listItem = try do
indent <- asks orgEnvIndentLevel
bullet <- unorderedBullet <|> counterBullet
hspace1 <|> lookAhead newline'
cookie <- optional counterSet
box <- optional checkbox
-- for the tag, previous horizontal space must have been consumed
tag <- case bullet of
Bullet _ -> option [] (toList <$> itemTag)
_ -> return []
els <- liftA2 (<>) (paraIndented (indent + 1) []) (elementsIndented (indent + 1))
return (ListItem bullet cookie box tag (toList els))
where
unorderedBullet = try $ Bullet <$> satisfy \c -> c == '+' || c == '-' || c == '*'
counterBullet = try do
counter <- digits1 <|> T.singleton <$> satisfy isAsciiAlpha
d <- satisfy \c -> c == '.' || c == ')'
pure (Counter counter d)
counterSet :: OrgParser Int
counterSet =
try $
string "[@"
*> parseNum
<* char ']'
<* hspace
where
parseNum = integer <|> asciiAlpha'
checkbox :: OrgParser Checkbox
checkbox =
try $
char '['
*> tick
<* char ']'
<* (hspace1 <|> lookAhead newline')
where
tick =
char ' ' $> BoolBox False
<|> char 'X' $> BoolBox True
<|> char '-' $> PartialBox
itemTag :: OrgParser OrgObjects
itemTag = withMContext (/= '\n') (not . isSpace) end (plainMarkupContext standardSet)
where
end = try do
hspace1
_ <- string "::"
hspace1 <|> lookAhead newline'
-- ** Greater blocks
-- | Parse a greater block.
greaterBlock :: OrgParser OrgElementData
greaterBlock = try do
_ <- string'' "#+begin_"
bname <- someNonSpace <* anyLine
els <- withContext anyLine (end bname) elements
return $ B.greaterBlock (blockType bname) els
where
blockType = \case
(T.toLower -> "center") -> Center
(T.toLower -> "quote") -> Quote
other -> Special other
end :: Text -> OrgParser Text
end name = try $ hspace *> string'' "#+end_" *> string'' name <* blankline'
-- verseBlock :: OrgParser OrgElements
-- verseBlock = try do
-- hspace
-- _ <- string'' "#+begin_verse"
-- undefined
-- where
-- end = try $ hspace *> string'' "#+end_export" <* blankline'
-- ** Drawers
-- | Parse a drawer.
drawer :: OrgParser OrgElementData
drawer = try do
_ <- char ':'
dname <- takeWhile1P (Just "drawer name") (\c -> c /= ':' && c /= '\n')
char ':' >> blankline
els <- withContext anyLine end elements
return $ B.drawer dname els
where
end :: OrgParser ()
end = try $ hspace <* string'' ":end:" <* blankline'
-- ** Footnote definitions
-- | Parse a footnote definition.
footnoteDef :: OrgParser OrgElementData
footnoteDef = try do
guard . (== 0) =<< asks orgEnvIndentLevel
lbl <- start
_ <- optional blankline'
def <-
withContext
anyLine
( lookAhead $
void headingStart
<|> try (blankline' *> blankline')
<|> void (try start)
)
elements
return $ B.footnoteDef lbl def
where
start =
string "[fn:"
*> takeWhile1P
(Just "footnote def label")
(\c -> isAlphaNum c || c == '-' || c == '_')
<* char ']'
-- ** Tables
-- | Parse a table.
table :: OrgParser OrgElementData
table = try do
_ <- lookAhead $ char '|'
rows <- some tableRow
return $ B.table rows
where
tableRow :: OrgParser TableRow
tableRow = ruleRow <|> columnPropRow <|> standardRow
ruleRow = try $ RuleRow <$ (hspace >> string "|-" >> anyLine')
columnPropRow = try do
hspace
_ <- char '|'
ColumnPropsRow
<$> some cell
<* blankline'
where
cell = do
hspace
Just <$> cookie <|> Nothing <$ void (char '|')
cookie = try do
a <-
string "<l" $> AlignLeft
<|> string "<c" $> AlignCenter
<|> string "<r" $> AlignRight
_ <- digits
_ <- char '>'
hspace
void (char '|') <|> lookAhead newline'
pure a
standardRow = try do
hspace
_ <- char '|'
B.standardRow
<$> some cell
<* blankline'
where
cell = do
hspace
char '|' $> mempty
<|> withMContext
(const True)
(\c -> not $ isSpace c || c == '|')
end
(plainMarkupContext standardSet)
end = try $ hspace >> void (char '|') <|> lookAhead newline'
-- * Lesser elements
-- ** Code
-- | Parse an example block.
exampleBlock :: OrgParser OrgElementData
exampleBlock = try do
_ <- string'' "#+begin_example"
switches <- blockSwitches
_ <- anyLine
contents <- rawBlockContents end switches
pure $ B.example switches contents
where
end = try $ hspace *> string'' "#+end_example" <* blankline'
-- | Parse a fixed width block.
fixedWidth :: OrgParser OrgElementData
fixedWidth = try do
contents <- SrcLine <<$>> some (hspace *> string ": " *> anyLine')
tabWidth <- getsO orgSrcTabWidth
preserveIndent <- getsO orgSrcPreserveIndentation
let lines' =
if preserveIndent
then map (srcLineMap (tabsToSpaces tabWidth)) contents
else indentContents tabWidth contents
pure $ B.example mempty lines'
-- | Parse a source block.
srcBlock :: OrgParser OrgElementData
srcBlock = try do
_ <- string'' "#+begin_src"
lang <- option "" $ hspace1 *> someNonSpace
switches <- blockSwitches
args <- headerArgs
contents <- rawBlockContents end switches
pure $ B.srcBlock lang switches args contents
where
end = try $ hspace *> string'' "#+end_src" <* blankline'
headerArgs :: OrgParser [(Text, Text)]
headerArgs = do
hspace
fromList
<$> headerArg
`sepBy` hspace1
<* anyLine'
where
headerArg =
liftA2
(,)
(char ':' *> someNonSpace)
( T.strip . fst
<$> findSkipping
(not . isSpace)
( try $
lookAhead
( newline'
<|> hspace1 <* char ':'
)
)
)
-- | Parse an export block.
exportBlock :: OrgParser OrgElementData
exportBlock = try do
_ <- string'' "#+begin_export"
format <- option "" $ hspace1 *> someNonSpace
_ <- anyLine
contents <- T.unlines <$> manyTill anyLine end
return $ B.export format contents
where
end = try $ hspace *> string'' "#+end_export" <* blankline'
indentContents :: Int -> [SrcLine] -> [SrcLine]
indentContents tabWidth (map (srcLineMap $ tabsToSpaces tabWidth) -> lins) =
map (srcLineMap $ T.drop minIndent) lins
where
minIndent = maybe 0 minimum1 (nonEmpty $ map (indentSize . srcLineContent) lins)
indentSize = T.length . T.takeWhile (== ' ')
tabsToSpaces :: Int -> Text -> Text
tabsToSpaces tabWidth txt =
T.span (\c -> c == ' ' || c == '\t') txt
& first
( flip T.replicate " "
. uncurry (+)
. bimap T.length ((* tabWidth) . T.length)
. T.partition (== ' ')
)
& uncurry (<>)
rawBlockContents :: OrgParser void -> Map Text Text -> OrgParser [SrcLine]
rawBlockContents end switches = do
contents <- manyTill (rawBlockLine switches) end
tabWidth <- getsO orgSrcTabWidth
preserveIndent <- getsO orgSrcPreserveIndentation
pure $
if preserveIndent || "-i" `member` switches
then map (srcLineMap (tabsToSpaces tabWidth)) contents
else indentContents tabWidth contents
quotedLine :: OrgParser Text
quotedLine = do
(<>)
<$> option "" (try $ char ',' *> (string "*" <|> string "#+"))
<*> anyLine
rawBlockLine :: Map Text Text -> OrgParser SrcLine
rawBlockLine switches =
try $ applyRef =<< quotedLine
where
(refpre, refpos) =
maybe
("(ref:", ")")
(second (T.drop 2) . T.breakOn "%s")
$ lookup "-l" switches
applyRef txt
| Just (content, ref, _) <- breakCap refCookie txt =
pure $ RefLine "" ref content
| otherwise = pure $ SrcLine txt
refCookie :: Parser Text
refCookie = do
space1 <* string refpre
toText
<$> someTill
(satisfy $ \c -> isAsciiAlpha c || isDigit c || c == '-' || c == ' ')
(string refpos)
blockSwitches :: OrgParser (Map Text Text)
blockSwitches = fromList <$> many (linum <|> switch <|> fmt)
where
linum :: OrgParser (Text, Text)
linum = try $ do
hspace1
s <-
T.snoc . one
<$> oneOf ['+', '-']
<*> char 'n'
num <- option "" $ try $ hspace1 *> takeWhileP Nothing isDigit
_ <- lookAhead spaceChar
return (s, num)
fmt :: OrgParser (Text, Text)
fmt = try $ do
hspace1
s <- string "-l"
hspace1
str <-
between (char '"') (char '"') $
takeWhileP Nothing (\c -> c /= '"' && c /= '\n')
_ <- lookAhead spaceChar
return (s, str)
switch :: OrgParser (Text, Text)
switch = try $ do
hspace1
s <-
T.snoc . one
<$> char '-'
<*> oneOf ['i', 'k', 'r']
_ <- lookAhead spaceChar
pure (s, "")
-- ** LaTeX
-- | Parse a LaTeX environment.
latexEnvironment :: OrgParser OrgElementData
latexEnvironment = try do
_ <- string "\\begin{"
ename <-
takeWhile1P
(Just "latex environment name")
(\c -> isAsciiAlpha c || isDigit c || c == '*')
_ <- char '}'
(str, _) <- findSkipping (/= '\\') (end ename)
return $ B.latexEnvironment ename $ "\\begin{" <> ename <> "}" <> str <> "\\end{" <> ename <> "}"
where
end :: Text -> OrgParser ()
end name = try $ string ("\\end{" <> name <> "}") *> blankline'
-- ** Keywords
affiliatedKeyword :: OrgParser (Text, KeywordValue)
affiliatedKeyword = try do
v <- keywordData
let name = fst v
unless ("attr_" `T.isPrefixOf` name) do
akws <- getsO orgElementAffiliatedKeywords
guard $ name `member` akws
return v
-- | Parse a keyword.
keyword :: OrgParser OrgElementData
keyword = uncurry B.keyword <$> keywordData
keywordData :: OrgParser (Text, KeywordValue)
keywordData = try do
_ <- string "#+"
-- This is one of the places where it is convoluted to replicate org-element
-- regexes: "#+abc:d:e :f" is a valid keyword of key "abc:d" and value "e :f".
name <-
T.toLower . fst <$> fix \me -> do
res@(name, _) <-
skipManyTill' (satisfy (not . isSpace)) $
try $
char ':' *> notFollowedBy me
guard (not $ T.null name)
pure res
hspace
if "attr_" `T.isPrefixOf` name
then do
args <- B.attrKeyword <$> headerArgs
return (name, args)
else do
text <- T.stripEnd <$> anyLine'
parsedKws <- getsO orgElementParsedKeywords
value <-
if name `member` parsedKws
then do
st <- getFullState
ParsedKeyword . toList
<$> parseFromText st text (plainMarkupContext standardSet)
else return $ ValueKeyword text
return (name, value)
-- ** Horizontal Rules
-- | Parse a horizontal rule.
horizontalRule :: OrgParser OrgElementData
horizontalRule = try do
l <- T.length <$> takeWhile1P (Just "hrule dashes") (== '-')
guard (l >= 5)
blankline'
return B.horizontalRule
-- ** Comments
-- | Parse a comment.
commentLine :: OrgParser OrgElementData
commentLine = try do
_ <- char '#'
blankline' <|> (char ' ' <|> fail "If this was meant as a comment, a space is missing here.") *> void anyLine'
pure Comment
-- | Parse a comment block.
commentBlock :: OrgParser OrgElementData
commentBlock = try do
_ <- string'' "#+begin_comment"
_ <- anyLine
_ <- skipManyTill anyLine end
pure Comment
where
end = try $ hspace *> string'' "#+end_comment" <* blankline'
clock :: OrgParser OrgElementData
clock = try do
_ <- string'' "clock: "
ts <- parseTimestamp
case ts of
TimestampData False _ -> do
blankline'
return $ B.clock ts Nothing
TimestampRange False _ _ -> do
t <- optional $ do
_ <- try $ do
hspace1
string "=>"
hspace1
parseTime
blankline'
return $ B.clock ts t
_ -> fail "Clock timestamp must be inactive."