Parse


1 Overview

We use Megaparsec to parse an Org document. The parsing requires us to read in everything in a single Org file.

We use a flat list of cells1 3 for parsing, where each cell can be a

This list-of-cells scheme is good for quickly making sense of the overall document structure in linear fashion, start to finish. It also simplifies the data structure --- we just parse one cell after the other. Though using a tree data structure for the headings could be useful for situations where tree-based manipulations are desirable, we will never need to do such manipulations for purposes of weaving the Org input into HTML (or any other format). Hence our preference for arranging the cells as a list instead of a tree.

In summary an Org document is made up of cells, as well as some metadata 2 (such as the title, author name, etc). The doc also has an OID 1, which allows other Org files to refer to it (even if the Org file does not have any headings, which are the only other things that can have OIDs).

1.1 OrgDoc data structure

The OrgDoc is important because it's what we end up with after we're done parsing a raw Org file.

data:OrgDoc
type OrgDoc :: Type
data OrgDoc = OrgDoc
  { oid :: OID, -- 1
    meta :: Map Text Text, -- 2
    cells :: [Cell], -- 3
    pseudoEdges :: [PseudoEdge], -- 4
    footnoteRefs :: Map Text (Int, LCI), -- 5
    glossary :: Map Text LCI, -- 6
    lineLabels :: Map Text Int, -- 7
    cslPath :: Maybe FilePath, -- 8
    bibPaths :: [FilePath], -- 9
    citations :: [Citation] -- 10
  }
  deriving stock (Eq, Ord, Generic, Show)
  deriving anyclass (FromJSON, ToJSON) -- 11

As stated earlier, we use a list of cells 3 to represent most of the document content. The Int value we use to index into this list of cells is given its own type newtype:LCI to avoid using the Int primitive (using Int everywhere can get confusing).

newtype:LCI
type LCI :: Type
newtype LCI = LCI
  { unLCI :: Int
  }
  deriving stock (Eq, Ord, Generic, Show)
  deriving anyclass (FromJSON, FromJSONKey, ToJSON)

The oid 1 is a unique identifier for the document. Every Org file we parse is required to have a

:PROPERTIES:
:ID:       xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
:END:

at the beginning of the document to uniquely identify it. It is parsed into an OID type, which just wraps around a UUID. This way we can be agnostic about the concrete details of a UUID in the rest of the program.

newtype:OID
type OID :: Type
newtype OID = OID UUID
  deriving stock (Eq, Ord, Generic)
  deriving anyclass (FromJSON, FromJSONKey, ToJSON)

There are some helpers in Org ID (OID) for this type, which plays a big role in Lilac because OIDs are also used for headings; OIDs for documents and headings form the building block of Org links between them.

The meta field 2 captures all document metadata, in a simple key/value map. One example is #+title: ... used to specify the human-friendly title of the document.

The deriving anyclass (ToJSON) 11 is needed to create the default implementation for JSON conversion automatically for us. It is the equivalent of writing a default instance like

instance ToJSON OrgDoc

but it's shorter because we don't have to put it on its own line, and it's even more closely coupled with the declaration of the OrgDoc type itself. If we try to use deriving stock (Eq, Generic, Show, ToJSON) we get an error like

‘ToJSON’ is not a stock derivable class (Eq, Show, etc.)

which is why we use the deriving anyclass variant. All child types also require the same deriving anyclass (ToJSON) declaration for this to work. At the end of it though, we can use the encode and encodeFile functions from Data.Aeson.

1.2 Auxiliary data structures

We also need several other auxiliary data structures to encode various bits of information beyond just what the cells have in them. Basically all of these auxiliary bits encode information about relationships between certain cells.

1.2.1 pseudoEdges

The pseudoEdges 4 are edges that are ultimately used to create a graph representation of links between cells (and ultimately they are finalized in f:finalizeEdges and used to generate backlinks at f:mkBacklinks). In that final representation, we want to use global cell index numbers to represent the universe of all cells in a data:Archive. However, inside the parser for a single Org document, we don't have access to this global cell index number, as that is computed separately in f:mkArchive (in other words, this computation depends on what other Org files (as objects) are included in the archive). Also, we don't know the target cell's number either, and the best information we have is just a data:LinkOid. Because of these issues, we call the edges here "pseudo" edges. To represent these edges, we use a newtype.

newtype:PseudoEdge
type PseudoEdge :: Type
newtype PseudoEdge = PseudoEdge (LCI, LinkOid)
  deriving stock (Eq, Ord, Generic, Show)
  deriving anyclass (FromJSON, ToJSON)

The newtype:PseudoEdge represents a link between a local cell index and a target cell via the data:LinkOid. Both of these values are immediately available to us, so that's why we use them for the pseudo edge representation (there's no need to compute these values lazily after the fact when there is more information available).

1.2.2 footnoteRefs

Footnotes get their own separate data structure called footnoteRefs 5, because we have to remember the order of when these footnotes are referenced from other cells (anywhere we could have prose, such as paragraphs, headings, etc). This ordering is needed to determine which number to display as a superscript when creating the link to the footnote definition. This same ordering information is used in f:weaveObject which rearranges the footnote definitions so that they are woven in the order they are referenced, not the order in which they are defined (parsed). This is unlike every other cell where the order of parsing them (cell-by-cell) is preserved during weaving.

1.2.3 glossary

For the glossary 6, see Description lists, aka glossary terms. We save all glossary terms we find for this document, so that we can print them out later, much like citations.

1.2.4 lineLabels

The lineLabels 7 are for Line labels (named positions inside a code block, which you can link to from the prose text). We reset the numbering of these back to 1 every time we see a new code block, so we keep track of the assigned numbers here with a map.

1.2.5 Citations

The cslPath 8 is the path to the citation style file we want to use for generating citations (for all of the citations used in the OrgDoc).

The bibPaths 9 is the list of files that each have a list of bibliographical works that we want to cite. This is a list of files, and not just a single list, because we want to allow users to be able to use multiple files in case they want to reference a work already captured in a separate file somewhere else (essentially amounting to file reuse).

Finally the citations 10 are the stylized citations used by the OrgDoc. See Citations for details.

1.3 Top-level parser parseOrgDoc

The top-level parsing function is f:parseOrgDoc, which takes some raw text input and parses it into an OrgDoc (data:OrgDoc), using the f:orgDocParser as the main workhorse 12.

f:parseOrgDoc
parseOrgDoc :: Text -> Either Text OrgDoc
parseOrgDoc input = case MP.runParser
  (runStateT orgDocParser defaultOrgParserState) -- 12
  ""
  input of
  Left err -> Left . T.pack $ MP.errorBundlePretty err
  Right (orgDoc, _pState) -> Right orgDoc

The f:orgDocParser is itself composed of other parser combinators. The hierarchy of the main parsers look like this:

All of these parsers live inside the type:OrgParser monadic type, which is defined as a state monad transformer (StateT) with data:OrgParserState, which wraps around the inner Parsec Void Text monad, where Void is the custom error component and Text is the type of the text being parsed. Using Void for the custom error component means that we'll be defaulting to the default error handling mechanism that ships with Megaparsec.

type:OrgParser
type OrgParser :: Type -> Type
type OrgParser = StateT OrgParserState (Concrete type for Megaparsec)

The Concrete type for Megaparsec feeds the concrete Text type to use as the stream for Megaparsec. Megaparsec defines a polymorphic API and expects users to use concrete types like this for speed optimizations.

Concrete type for Megaparsec
MP.ParsecT Void Text Identity

1.4 OrgParserState

The data:OrgParserState is the custom state we want to use while parsing. It's a mutable variable which we keep updating as we make progress during parsing.

data:OrgParserState
type OrgParserState :: Type
data OrgParserState = OrgParserState
  { docOid :: OID, -- 13
    headingOidStack :: [Maybe OID], -- 14
    headingLevel :: Int, -- 15
    headingSectionNumber :: [Int], -- 16
    insideHeading :: Bool, -- 17
    cellNames :: Set Text, -- 18
    cellNumber :: LCI, -- 19
    pseudoEdges :: [PseudoEdge], -- 20
    tanglePaths :: Set Text, -- 21
    insideCodeBlock :: Bool, -- 22
    anonBlockNumber :: Int, -- 23
    lineLabelNumber :: Int, -- 24
    lineLabels :: Map Text Int, -- 25
    styleMask :: StyleMask, -- 26
    insideLinkDisplayName :: Bool, -- 27
    insideParagraph :: Bool, -- 28
    listLevels :: [Level], -- 29
    listMarkers :: [ListMarker], -- 30
    continuationIndent :: Int, -- 31
    footnoteNumber :: Int, -- 32
    footnoteRefs :: Map Text (Int, LCI), -- 33
    footnoteDefinitions :: Map Text Prose, -- 34
    insideFootnoteDefinition :: Maybe Text, -- 35
    prevCellIsFootnote :: Bool, -- 36
    glossary :: Map Text LCI, -- 37
    glossaryTermOffset :: Int, -- 38
    cslPath :: Maybe FilePath, -- 39
    bibPaths :: [FilePath], -- 40
    insideCitation :: Bool, -- 41
    insideCitePrefix :: Bool, -- 42
    bracketBalance :: Int, -- 43
    citations :: [Citation], -- 44
    equationNumber :: Int, -- 45
    anonEquationNumber :: Int, -- 46
    figureNumber :: Int, -- 47
    tableNumber :: Int, -- 48
    orgParserConf :: OrgParserConf -- 49
  }
  deriving stock (Eq, Show)

Running the orgDocParser 12 requires a default set of values for the OrgParserState, which we provide with f:defaultOrgParserState.

f:defaultOrgParserState
defaultOrgParserState :: OrgParserState
defaultOrgParserState =
  OrgParserState
    { docOid = nilOid,
      headingOidStack = [],
      headingLevel = 0,
      headingSectionNumber = [],
      insideHeading = False,
      cellNames = Set.empty,
      cellNumber = LCI 0,
      pseudoEdges = [],
      tanglePaths = Set.empty,
      insideCodeBlock = False,
      anonBlockNumber = 0,
      lineLabelNumber = 0,
      lineLabels = M.empty,
      styleMask = styleMaskFrom [],
      insideLinkDisplayName = False,
      insideParagraph = False,
      listLevels = [],
      listMarkers = [],
      continuationIndent = 0,
      footnoteNumber = 0,
      footnoteRefs = M.empty,
      footnoteDefinitions = M.empty,
      insideFootnoteDefinition = Nothing,
      prevCellIsFootnote = False,
      glossary = M.empty,
      glossaryTermOffset = 0,
      cslPath = Nothing,
      bibPaths = [],
      insideCitation = False,
      insideCitePrefix = False,
      bracketBalance = 0,
      citations = [],
      equationNumber = 0,
      anonEquationNumber = 0,
      figureNumber = 0,
      tableNumber = 0,
      orgParserConf = defaultOrgParserConf
    }

Now let's examine all of the various fields of data:OrgParserState.

1.4.1 Fields for headings

There are a couple things to keep track of for Headings.

  1. Headings may or may not have their own OID. Typically, OIDs for headings are only created when running org-store-link in Emacs, which will automatically create one for us when we want to refer to a named cell (you put your cursor over the cell and run org-store-link). All named cells within a heading use the closest OID when going up the heading hierarchy.

  2. Headings also have a section number, starting with 1 and incrementing with subsequent headings at the same level. Each level has its own counter, separated with a dot. E.g., "1.2" and "2.13" are valid section numbers. We need to keep track of these numbers (and know how to increment them, etc), as we parse headings at different levels.

The fields below mostly deal with one of these two concerns.

The docOid 13 is what becomes the oid 1 of data:OrgDoc. If there are no headings, then this docOid is what is used as a unique prefix to identify named cells (any cell with a #+name: ..., such as a code block) from other Org files.

The headingOidStack 14 is a stack of OIDs, with the first one being the docOid and later ones being OIDs of headings encountered at deeper levels. This is used to keep track of the current OID to use for named cells; if we are at a deeply nested heading with its own OID, but then move out of it, we need to know which OID to use. Using headingOidStack, we just pop off those ones we don't need any more and refer to the OID of the closest heading up the hierarchy (ending at the docOid if needed as a fallback).

The headingLevel 15 is the last-seen heading level we parsed. See t:0004-headings-cannot-skip-level for how we use it. This is used for determining how many levels to pop off the headingOidStack when we exit a nested level. It's also used to calculate the headingSectionNumber.

The headingSectionNumber 16 is the last-generated heading section number. This is what we use to assign each new heading its section number (by incrementing the correct part of it).

The insideHeading 17 boolean is used to keep track of whether we are currently parsing things from inside a heading or not. This is used, for example, to disable parsing of citations from inside headings; i.e., citations are not recognized when they are placed inside headings (see f:proseParser).

1.4.2 Fields for cells

Cell names must be unique inside a single Org file, and this is tracked with cellNames 18.

All cells, even if they are not named, are sequentially numbered in cellNumber 19. This is used to encode the starting vertex of each edge in pseudoEdges 20 (see pseudoEdges).

1.4.3 Fields for code blocks

The tanglePaths 21 is used in f:assertUniqueTanglePath to check that no two blocks tangle to the same filesystem path.

The insideCodeBlock boolean 22 tracks whether we're inside a code block; see f:compressedTextParser for how this is used.

The anonBlockNumber 23 is used to keep track of all anonymous blocks. Anonymous blocks can link out to other named blocks, and for such links we have to refer back to the anonymous block. As these blocks are anonymous we have to give them an artificial name, which is the anonymous block number (a sequential counter). See f:blockParser for details.

1.4.4 Fields for line labels

The lineLabelNumber 24 is a sequential counter for Line labels which is reset at every code block.

The lineLabels map 25 associates the label with the label number, and this number is used to display the link in the text (source) as well as the code block (destination).

1.4.5 Fields for prose text

The styleMask 26 is discussed in the section on parsing styled strings, which are one of the main building blocks of Prose.

The insideLinkDisplayName 27 and insideParagraph 28 booleans are both used in f:textParser.

1.4.6 Fields for list items

The listLevels 29 and listMarkers 30 are discussed in List items.

Indentation is very important for knowing which things belong under which list items, and the continuationIndent 31 is used to keep track of how much indentation to expect inside a single (contiguous) piece of content; see f:paragraphParser and f:listHeadIndentLevelParser for a usage example.

1.4.7 Fields for footnotes

Footnotes are numbered (footnoteNumber 32), and also have an association with cell numbers (footnoteRefs 33). The footnote definitions are kept in a single map (footnoteDefinitions 34).

Like other booleans that tweak the behavior of the parser, footnotes also need to do this via insideFootnoteDefinition 35.

Footnote definitions must be kept in a single group of contiguous cells, and this is checked with prevCellIsFootnote 36 (also see f:footnoteDefinitionParser).

1.4.8 Fields for the glossary

Description lists, aka glossary terms are tracked with the glossary 37. The glossaryTermOffset 38 is used to improve error messages (see t:0015-glossary-duplicate-term).kj

1.4.9 Fields for citations

Citations require a number of fields. The cslPath 39, bibPaths 40, and citations 44 fields are the same in purpose as the ones discussed at Citations up above.

The insideCitation 41, insideCitePrefix 42, and bracketBalance 43 fields all deal with the lower level details of parsing these citations correctly (being strict about what we accept).

1.4.10 Fields for miscellaneous sequential counters

Mathematical equations, figures, and tables all need to get numbered. These are handled by equationNumber 45, anonEquationNumber 46, figureNumber 47, and tableNumber 48.

1.4.11 Fields for Lilac extensions

Lilac extensions can alter the behavior of the parser. These are encapsulated in the data:OrgParserConf, which we capture in orgParserConf 49.

2 Lilac.Parse module

The parsing mechanism is in the Lilac.Parse module. We discuss each parser and interleave some tests for the smaller parsers. If you are curious about the overall testing framework itself (where and how these interleaved tests are executed), take a look at the Testing framework.

module:Lilac.Parse
🎯 src/Lilac/Parse.hs
{-# OPTIONS_GHC -fno-warn-orphans #-}

module Lilac.Parse
  ( compressProse,
    compressStyledText,
    defaultOrgDoc,
    defaultOrgParserConf,
    genSectionNumber,
    genHeadingOidStack,
    isLineLabel,
    isNumericText,
    maskToStyles,
    orgDocParser,
    parseOrgDoc,
    styleMaskFrom,
    tableHorizontalLine,
    unparseLink,
    unparseMathFragment,
    validateOidText,
  )
where

import Citeproc
  ( CiteprocOutput,
    addDisplay,
    addFontStyle,
    addFontVariant,
    addFontWeight,
    addHyperlink,
    addQuotes,
    addTextCase,
    addTextDecoration,
    addVerticalAlign,
    dropTextWhile,
    dropTextWhileEnd,
    fromText,
    inNote,
    localizeQuotes,
    mapText,
    movePunctuationInsideQuotes,
    toText,
  )
import Citeproc.Types
  ( DisplayStyle
      ( DisplayBlock,
        DisplayIndent,
        DisplayLeftMargin,
        DisplayRightInline
      ),
    FontStyle
      ( ItalicFont,
        NormalFont,
        ObliqueFont
      ),
    FontVariant
      ( NormalVariant,
        SmallCapsVariant
      ),
    FontWeight
      ( BoldWeight,
        LightWeight,
        NormalWeight
      ),
    TextCase
      ( CapitalizeAll,
        CapitalizeFirst,
        Lowercase,
        PreserveCase,
        SentenceCase,
        TitleCase,
        Uppercase
      ),
    TextDecoration
      ( NoDecoration,
        UnderlineDecoration
      ),
    VerticalAlign
      ( BaselineAlign,
        SubAlign,
        SupAlign
      ),
  )
import Data.Bits (setBit, testBit, (.^.))
import Data.Bits.Bitwise (toListLE)
import Data.Char
  ( GeneralCategory (CurrencySymbol),
    generalCategory,
    isAsciiLower,
    isAsciiUpper,
    isDigit,
    isSpace,
  )
import Data.List (groupBy)
import Data.Map.Strict qualified as M
import Data.Set qualified as Set
import Data.Text qualified as T
import Lilac.Types
  ( BToken (BTokenLinkTarget, BTokenPadding, BTokenString),
    Block (Block, body, meta, parentOid),
    Cell
      ( CBlock,
        CCommand,
        CFigure,
        CFootnote,
        CGlossaryTerm,
        CHeading,
        CListItem,
        CMathFragment,
        CParagraph,
        CTable
      ),
    Citation (Citation, cites, noteNumber, prefix, styleVariation, suffix),
    Cite (Cite, id, locator, prefix, suffix),
    Command (PrintBibliography, PrintGlossary),
    Figure (Figure, caption, link, meta, parentOid),
    Footnote (Footnote, body, name),
    GlossaryTerm (GlossaryTerm, definition, term),
    Heading (Heading, body, level, meta, section),
    LCI (LCI, unLCI),
    Level (Level, indent, number),
    Link (Link, name, target),
    LinkOid (LinkOid, name, oid),
    LinkTarget
      ( LinkTargetFilePath,
        LinkTargetFootnote,
        LinkTargetLineLabel,
        LinkTargetOid,
        LinkTargetURI
      ),
    ListMarker
      ( LItemEnd,
        LItemStart,
        LOrderedEnd,
        LOrderedStart,
        LUnorderedEnd,
        LUnorderedStart
      ),
    MathFragment (MathFragment, body, meta, parentOid),
    OID,
    OrgDoc
      ( OrgDoc,
        bibPaths,
        cells,
        citations,
        cslPath,
        footnoteRefs,
        glossary,
        lineLabels,
        meta,
        oid,
        pseudoEdges
      ),
    OrgParser,
    OrgParserConf (maxHeadingLevel, maxListLevel),
    PToken
      ( PTokenCitation,
        PTokenDivEnd,
        PTokenDivStart,
        PTokenLink,
        PTokenMathFragment,
        PTokenStyledText
      ),
    Prose (Prose, unProse),
    PseudoEdge (PseudoEdge),
    Style
      ( Bold,
        Code,
        Italic,
        Smallcaps,
        Subscript,
        Superscript,
        Underline,
        Verbatim
      ),
    StyleMask (StyleMask),
    StyledText (StyledText, body, style),
    Table (Table, caption, grid, meta, parentOid),
    defaultOrgParserConf,
    defaultOrgParserState,
    fromText,
    markingChars,
    nilOid,
    protocolsEmacs,
    protocolsWeb,
    styleMaskFrom,
  )
import Lilac.Types as OrgParserState
  ( OrgParserState
      ( anonBlockNumber,
        anonEquationNumber,
        bibPaths,
        bracketBalance,
        cellNames,
        cellNumber,
        citations,
        continuationIndent,
        cslPath,
        docOid,
        equationNumber,
        figureNumber,
        footnoteDefinitions,
        footnoteNumber,
        footnoteRefs,
        glossary,
        glossaryTermOffset,
        headingLevel,
        headingOidStack,
        headingSectionNumber,
        insideCitation,
        insideCitePrefix,
        insideCodeBlock,
        insideFootnoteDefinition,
        insideHeading,
        insideLinkDisplayName,
        insideParagraph,
        lineLabelNumber,
        lineLabels,
        listLevels,
        listMarkers,
        orgParserConf,
        prevCellIsFootnote,
        pseudoEdges,
        styleMask,
        tableNumber,
        tanglePaths
      ),
  )
import Network.URI qualified as N
import Relude
  ( Bool (False, True),
    Char,
    Either (Left, Right),
    FilePath,
    Int,
    Map,
    Maybe (Just, Nothing),
    MonadFail,
    Ord,
    String,
    Text,
    abs,
    catMaybes,
    concatMap,
    drop,
    elem,
    error,
    fail,
    filter,
    find,
    flip,
    fmap,
    foldl',
    forM_,
    fromEnum,
    fromList,
    fromMaybe,
    fst,
    get,
    id,
    isJust,
    isNothing,
    length,
    many,
    map,
    mapM_,
    maybeToList,
    modify,
    not,
    notElem,
    null,
    optional,
    or,
    otherwise,
    pure,
    put,
    readMaybe,
    repeat,
    replicate,
    reverse,
    runStateT,
    show,
    sort,
    take,
    toEnum,
    toList,
    unwords,
    void,
    when,
    zip,
    ($),
    (&&),
    (*),
    (+),
    (-),
    (.),
    (/=),
    (<),
    (<$>),
    (<=),
    (<>),
    (<|>),
    (=<<),
    (==),
    (>),
    (>=),
    (>>),
    (||),
  )
import Relude.Extra.Map
  ( delete,
    insert,
    keys,
    lookup,
    member,
    notMember,
  )
import Relude.Unsafe (fromJust)
import System.OsPath qualified as P
import Text.Megaparsec qualified as MP
import Text.Megaparsec.Char (char, string)
import Text.Megaparsec.Char.Lexer qualified as L

f:skipNewlinesAndComments
f:lexeme
f:parseOrgDoc
f:orgDocParser
f:defaultOrgDoc
f:docMetaEntryParser
f:docMetaParser
f:lilacExtensionsParser
f:maxHeadingLevelParser
f:maxListLevelParser
f:signedIntParser
f:cellParser
f:detectInvalidIndentation
f:headingParser
f:failIfHeadingNestsWithoutDirectParent
f:genSectionNumber
f:genHeadingOidStack
f:propertiesDrawerParser
f:propertiesParser
f:propertiesEntryParser
f:proseParser
f:proseSpaceConsumer
f:whitespaceCompressor
f:onlySpaces
f:styleMarkers
f:failIfLeftoverActiveStyles
f:styleMarkerParser
f:isStyleMarker
f:styledTextParser
f:textParser
f:escapedSymbolParser
f:verbatimCodeParser
f:slashSeparatedWords
f:maskToStyles
f:toStyleMask
f:styleMaskFlip
f:isVerbatim
f:isCode
f:isEmptyMask
f:compressProse
f:compressStyledText
f:assertUniqueCellName
f:assertUniqueLineLabel
f:linkParser
f:linkDisplayNameParser
f:failIfVerbatimOrCode
f:protocolWebLinkParser
f:fileLinkParser
f:rawLinkParser
f:mathFragmentInlineParser
f:paragraphParser
f:newlineAndIndentParser
f:cellBoundary
f:blockMetaEntryParser
f:assertUniqueTanglePath
f:blockParser
f:firstParentOid
f:blockStartParser
f:bTokenParser
f:zeroBTokensParser
f:someBTokensParser
f:nowebLinkTargetParser
f:lineLabelParser
f:oidLinkParser
f:isLineLabel
f:oidReferenceParser
f:compressedTextParser
f:ignoreSpecialCommas
f:compressBTokens
f:padConsecutiveNowebLinks
f:getLinkPaddingBools
f:nowebLinkAtEndOfLine
f:getBlockContents
f:zeroIndent
f:someIndent
f:getContinuationIndentForLevel
f:listItemsEndParser
f:getEndingListMarkers
f:listHeadIndentLevelParser
f:orderedHeadParser
f:unorderedHeadParser
f:listItemStartParser
f:listItemNester
f:listItemFitter
f:mathFragmentNumberedParser
f:trimTrailingWhitespace
f:mathFragmentUnnumberedParser
f:mathFragmentStandaloneParser
f:figureParser
f:metaParser
f:metaEntryParser
f:tableParser
f:tableFormulaParser
f:captionParser
f:tableLineParser
f:tableHorizontalLine
f:tableRowParser
f:tableCellParser
f:rowsToTuples
f:rowToTuples
f:footnoteLinkParser
f:footnoteReferenceParser
f:footnoteDefinitionParser
f:reconcileFootnotes
f:convertToGlossaryTerm
f:assertUniqueGlossaryTerm
f:isGlossaryTermDelimiter
f:glossaryLocationParser
f:validateOidText
f:validateCslPath
f:collectValsFor
f:bibliographyLocationParser
instance:CiteprocOutput Prose
f:unparseStyledText
f:unparseLink
f:unparseMathFragment
f:dropTextFromStyledText
f:citationParser
f:collectCitations
f:resetState
f:deleteRedundantKeys
f:failIfUnbalancedBrackets
f:citeParser
f:citeIdParser
f:citeLocatorParser
f:citeLocatorTermParser
f:citeLocatorCoordsParser
f:citeLocatorCoordParser
f:citeLocatorCoordRangeParser
f:citeAffixParser
f:isNumericText
f:isNumericTextParser
f:isPunctuation
f:isAlphaNumChar
f:isAlphaChar
f:isDigitChar
f:intraCellWhitespaceParser

3 orgDocParser

At a high level, the f:orgDocParser delegates most of the work to the f:cellParser, which in turn delegates work again to the individual parsers that know how to parse different cells. We have to filter out cells that look like CListItem [] with meaningfulCell 52 because they are a by-product of parsing List items (this can happen when f:listItemsEndParser detects maximum indentation that matches the most-deeply-nested item (in which case no list items end)).

f:orgDocParser
orgDocParser :: OrgParser OrgDoc
orgDocParser = do
  _ <- optional skipNewlinesAndComments -- 50
  (docOid, cslPath, bibPaths, meta) <- docMetaParser -- 51
  _ <- lilacExtensionsParser
  cells <- many cellParser
  MP.eof
  footnoteRefs <- reconcileFootnotes
  citations <- collectCitations
  pState <- get
  resetState
  let meaningfulCell :: Cell -> Bool
      meaningfulCell = \case
        CListItem [] -> False
        CHeading {}
        CListItem {}
        CParagraph {}
        CGlossaryTerm {}
        CBlock {}
        CMathFragment {}
        CFigure {}
        CTable {}
        CFootnote {}
        CCommand {} -> True
  let meaningfulCells = filter meaningfulCell cells -- 52
  when (pState.cellNumber.unLCI /= length meaningfulCells)
    $ fail
      ( "mismatch: cellNumber: "
          <> show pState.cellNumber
          <> " len meaningfulCells: "
          <> (show $ length meaningfulCells)
      )
  pure
    $ OrgDoc
      { oid = docOid,
        cells = meaningfulCells,
        pseudoEdges = pState.pseudoEdges,
        footnoteRefs = footnoteRefs,
        glossary = pState.glossary,
        lineLabels = pState.lineLabels,
        cslPath = cslPath,
        bibPaths = bibPaths,
        citations = citations,
        meta = deleteRedundantKeys meta ["cite_export"] -- 53
      }

We now look at some of the less obvious parts (other than f:cellParser for Cells) first:

3.1 Whitespace and comments

Before writing our first parsers, we have to consider the problem of whitespace and comments.

Any syntax that is expected to be used by humans have some designation for whitespace to separate meaningful tokens from each other. One way to deal with (necessary) whitespace is to bake in whitespace parsers into each token we want to parse. This gets cumbersome so we want to instead be able to teach parsers to be able to skip over trailing whitespace. This way, these parsers will all know how to deal with whitespace in a uniform way. For example, this is what we do in f:cellParser where we wrap all of the major Cell parsers with a lexeme.

A slightly different problem is how to deal with comments. In a sense they are just like whitespace because they hold no meaningful information on their own. Luckily, Text.Megaparsec.Char.Lexer provides a space helper which can create a parser that skips over whitespace as well as comments. This is what we use below to define f:skipNewlinesAndComments. See the Megaparsec tutorial for more information.

f:skipNewlinesAndComments
skipNewlinesAndComments :: OrgParser ()
skipNewlinesAndComments =
  L.space whiteSpaceParser singleLineCommentParser blockCommentParser -- 54
  where
    whiteSpaceParser :: OrgParser ()
    whiteSpaceParser = do
      _ <- MP.takeWhile1P (Just "newline") (== '\n') -- 55
      pState <- get
      _ <-
        MP.optional
          . MP.label "indentation for next thing"
          $ MP.count pState.continuationIndent (char ' ')
      pure ()
    singleLineCommentParser :: OrgParser ()
    singleLineCommentParser = L.skipLineComment "# " -- 56
    blockCommentParser :: OrgParser ()
    blockCommentParser =
      L.skipBlockComment
        "#+begin_comment\n"
        "#+end_comment\n" -- 57

The three arguments to L.space 54 (where L stands for Text.Megaparsec.Char.Lexer) are the parsers for whitespace, single-line comments, and block comments. L.space will try to repeatedly apply the three given parsers; it only stops parsing when faced with input where all three parsers fail.

We only parse newline characters in whiteSpaceParser 55. We don't parse spaces. This is because we do not want to skip over any indentation --- that is, space characters immediately following a newline. Such indentation could be an error (for example, for headings or paragraphs), or a necessary part of a cell (such as CListItem cells in f:listItemsEndParser), and so we leave that determination up to other parsers.

It's important that we use takeWhile1P for whiteSpaceParser (where the 1 denotes that it requires at least 1 newline character) because otherwise if it is allowed to accept 0 newlines (as with takeWhileP), it may loop forever upon seeing non-newline input.

Single-line comments use "# " 56, whereas multi-line comments expect an opening and closing delimiter 57. We include explicit newlines in blockCommentParser because we want to ensure that block comment markers are always on their own line (e.g., #+end_comment is valid, but #+end_comment foo is not).

Text.Megaparsec.Char.Lexer also defines a L.skipBlockCommentNested helper, which is able to understand if the ending marker of a block (#+end_comment\n for us) should be ignored because it's nested and instead skip past it to find the true (non-nested) ending marker. That's a little too complicated, so we use the non-nested L.skipBlockComment to keep the syntax simple.

Lastly we have f:lexeme, which wraps around any other parser to make it consume trailing newlines and comments. This is used in f:cellParser. We just reuse f:skipNewlinesAndComments as the main workhorse here.

f:lexeme
lexeme :: OrgParser a -> OrgParser a
lexeme = L.lexeme skipNewlinesAndComments

3.2 docMetaParser

An Org document's metadata is the first meaningful thing that could be parsed. Metadata can be created with a :PROPERTIES: drawer, or with a line that looks like #+key: value where key is a string and value is a series of tokens (it can have multiple words in it). The key and value syntax is trickier, so we look at that first with f:docMetaEntryParser below.

f:docMetaEntryParser
docMetaEntryParser :: OrgParser (Text, Text)
docMetaEntryParser = do
  avoidParsingBlockMetadata -- 58
  avoidParsingLilacExtensions -- 59
  _ <- MP.notFollowedBy $ string "#+__" -- 60
  _ <- string "#+"
  key <- MP.takeWhile1P (Just "(doc meta) keyword letter") isKeywordLetter
  _ <- MP.label "(doc meta) separator" $ string ": "
  _ <- many $ char ' '
  value <- MP.takeWhile1P (Just "(doc meta) value letter") (\c -> c /= '\n')
  pure (key, value)
  where
    isKeywordLetter c = or $ map ($ c) [isAsciiLower, isAsciiUpper, (== '_')]
    avoidParsingBlockMetadata :: OrgParser ()
    avoidParsingBlockMetadata =
      mapM_
        MP.notFollowedBy
        $ map
          string
          [ "#+begin_",
            "#+name: ",
            "#+header: ",
            "#+print_bibliography:",
            "#+print_glossary:"
          ]
    avoidParsingLilacExtensions :: OrgParser ()
    avoidParsingLilacExtensions = MP.notFollowedBy $ string "#+lilac_"

We have to tiptoe around code blocks by checking that it is not looking at block-related metadata syntax with avoidParsingBlockMetadata 58. This is important because otherwise if an Org document begins with a code block as the first thing (before headings or anything else), we may consume that as part of docMetaParser and fail because we think we are expecting : followed by a value when we encounter #+begin_src ....

Similarly, we also do not parse any Lilac extensions 59 because they can only be parsed by the f:lilacExtensionsParser.

We avoid parsing keys with a double underscore 60, because that's reserved for internal use by Lilac. Having the restriction at 60 allows the failure condition at 113 in f:metaEntryParser to trigger.

The f:docMetaParser first checks for the top-level :PROPERTIES: drawer with f:propertiesDrawerParser. Then it just parses f:docMetaEntryParser multiple times, while consuming any trailing whitespace or comments with f:lexeme.

f:docMetaParser
docMetaParser :: OrgParser (OID, Maybe FilePath, [FilePath], Map Text Text)
docMetaParser = do
  topLevelDrawerProps <- propertiesDrawerParser
  _ <- optional skipNewlinesAndComments
  metaKVsList <- many $ lexeme docMetaEntryParser
  let (bibPathsText, remainingKVs) = collectValsFor "bibliography" metaKVsList
      metaKVs = fromList remainingKVs
      bibPaths = map T.unpack bibPathsText
  let conflictingKeys = keys $ M.intersection topLevelDrawerProps metaKVs
  when (not $ null conflictingKeys)
    $ fail
    $ "conflicting keys in the top-level PROPERTIES drawer "
    <> "and document metadata key/values: "
    <> (show conflictingKeys)
  case lookup "ID" topLevelDrawerProps of
    Nothing -> fail $ "required document ID property not found"
    Just oidText -> do
      oid <- validateOidText oidText -- 61
      cslPath <- case lookup "cite_export" metaKVs of
        Just maybeCslPath -> validateCslPath maybeCslPath
        Nothing -> pure Nothing
      modify $ \s ->
        s
          { OrgParserState.bibPaths = bibPaths,
            OrgParserState.cslPath = cslPath,
            docOid = oid
          }
      pure (oid, cslPath, bibPaths, topLevelDrawerProps <> metaKVs)

OID validation in f:validateOidText 61 delegates to the f:fromText helper from Lilac.Types.

f:validateOidText
validateOidText :: (MonadFail m) => Text -> m OID
validateOidText oidText = case Lilac.Types.fromText oidText of
  Nothing -> fail $ show oidText <> " is not a valid OID"
  Just oid -> pure oid

Note that using the properties drawer is the main way in which the ID field of a document is created, which is then used by Orgmode (see org-id-link-to-org-use-id, org-id-link-consider-parent-id, and org-id-locations-file). Using ID's for things (instead of the file name) allows us to create links that are more resilient against file name changes (or directory changes). See Drawers for more discussion about properties drawers and ti:0002-doc-metadata-properties-drawer.

The special key #+cite_export: ... is parsed separately as the cslPath, using the f:validateCslPath helper. This is needed for supporting Citations.

3.2.1 Lilac extensions

Lilac supports extensions to alter the behavior of the parser for the document being parsed. These extensions are all prefixed by lilac_ and are set like other document metadata; they must come after the other (typical) Org metadata.

Below is a list of supported extensions:

These extensions are encoded inside the data:OrgParserConf.

data:OrgParserConf
type OrgParserConf :: Type
data OrgParserConf = OrgParserConf
  { maxHeadingLevel :: Int,
    maxListLevel :: Int
  }
  deriving stock (Eq, Show)

The default preferences are saved in f:defaultOrgParserConf.

f:defaultOrgParserConf
defaultOrgParserConf :: OrgParserConf
defaultOrgParserConf =
  OrgParserConf
    { maxHeadingLevel = 3,
      maxListLevel = 3
    }

If you want to deviate from the defaults, you have to specify these in your top-level document metadata, for f:lilacExtensionsParser to pick up.

f:lilacExtensionsParser
lilacExtensionsParser :: OrgParser ()
lilacExtensionsParser = do
  void
    . many
    $ MP.choice
      [ maxHeadingLevelParser,
        maxListLevelParser
      ]

The f:maxHeadingLevelParser resembles the f:docMetaEntryParser, but the main difference is that it does not return anything (its type is OrgParser ()). Instead it just modifies the pState.

f:maxHeadingLevelParser
maxHeadingLevelParser :: OrgParser ()
maxHeadingLevelParser = do
  pState <- get
  _ <- string $ "#+lilac_maxHeadingLevel: "
  _ <- many $ char ' '
  val <- signedIntParser
  let opc = pState.orgParserConf {maxHeadingLevel = val}
  put pState {orgParserConf = opc}

The f:signedIntParser knows how to parse a signed integer. The parserBetweenSignAndNumber is the parser for parsing the area between the sign (-) and the number. Here we define it as pure () meaning that nothing is allowed. The usual use case here is to use a whitespace parser, so as to allow a space between the sign and number, but we don't do this here for simplicity.

f:signedIntParser
signedIntParser :: OrgParser Int
signedIntParser = L.signed parserBetweenSignAndNumber number
  where
    number :: OrgParser Int
    number = L.lexeme skipNewlinesAndComments $ L.decimal
    parserBetweenSignAndNumber :: OrgParser ()
    parserBetweenSignAndNumber = pure ()

f:maxListLevelParser closely resembles f:maxHeadingLevelParser, and similarly updates the orgParserConf field inside f:defaultOrgParserState.

f:maxListLevelParser
maxListLevelParser :: OrgParser ()
maxListLevelParser = do
  pState <- get
  _ <- string $ "#+lilac_maxListLevel: "
  _ <- many $ char ' '
  val <- signedIntParser
  let opc = pState.orgParserConf {maxListLevel = val}
  put pState {orgParserConf = opc}

3.3 Resetting of state

We call f:resetState in f:orgDocParser to clear some state from data:OrgParserState, because otherwise it gets too polluted during testing. Also, it's not like we're throwing away information because everything we need is still captured elsewhere (just check the fields that have the same name in both data:OrgParserState and data:OrgDoc).

f:resetState
resetState :: OrgParser ()
resetState =
  modify $ \s ->
    s
      { OrgParserState.bibPaths = [],
        OrgParserState.citations = [],
        OrgParserState.cslPath = Nothing,
        OrgParserState.cellNumber = LCI 0,
        OrgParserState.pseudoEdges = []
      }

In f:resetState we cannot simply refer to the field alone in the record update, like this

  modify (\s -> s {citations = []})

because it will result in an "Ambiguous record update" error, because both data:OrgDoc and data:OrgParserState have a field named citations. So we work around this by importing the latter through import Lilac.Types as OrgParserState in module:Lilac.Parse. See this discussion from 2024 for more information.

In a similar vein to f:resetState, f:deleteRedundantKeys 53 removes certain entries from the given map data structure. In the case of f:orgDocParser, it removes keys that won't be used because the information in those keys have already been parsed and saved elsewhere.

f:deleteRedundantKeys
deleteRedundantKeys :: (Ord k) => Map k v -> [k] -> Map k v
deleteRedundantKeys = foldl' (flip M.delete)

3.4 Cells

The cell is the main building block of an Org document --- it is roughly any region of text surrounded by blank lines.

data:Cell
type Cell :: Type
data Cell
  = CHeading Heading
  | CListItem [ListMarker]
  | CParagraph Prose
  | CBlock Block
  | CMathFragment MathFragment
  | CFigure Figure
  | CTable Table
  | CFootnote Footnote
  | CGlossaryTerm GlossaryTerm
  | CCommand Command
  deriving stock (Eq, Ord, Generic, Show)
  deriving anyclass (FromJSON, ToJSON)

All of the different cell types describe some sort of content, except for one, CListItem. The CListItem is purely structural because it only describes itself and relies on other cells for displaying any useful information. See List items to see how this is used.

The data:Command encodes information about generated content, such as Printing bibliographies.

data:Command
type Command :: Type
data Command
  = PrintBibliography
  | PrintGlossary Bool
  deriving stock (Eq, Ord, Generic, Show)
  deriving anyclass (FromJSON, ToJSON)

The f:cellParser is responsible for parsing these different recognized values of data:Cell.

f:cellParser
cellParser :: OrgParser Cell
cellParser = do
  cell <-
    lexeme
      $ MP.choice
        [ toggleFootnote False $ listItemStartParser,
          toggleFootnote False $ listItemsEndParser,
          toggleFootnote False $ resetIndent headingParser,
          toggleFootnote False $ resetIndent blockParser,
          toggleFootnote False $ resetIndent figureParser,
          toggleFootnote False $ resetIndent tableParser,
          toggleFootnote False $ resetIndent mathFragmentStandaloneParser,
          toggleFootnote True $ resetIndent footnoteDefinitionParser,
          toggleFootnote False $ resetIndent bibliographyLocationParser,
          toggleFootnote False $ resetIndent glossaryLocationParser,
          toggleFootnote False $ resetIndent paragraphParser,
          detectInvalidIndentation
        ]
  modify (\s -> s {bracketBalance = 0}) -- 62
  when (cell /= CListItem [])
    $ modify (\s -> s {cellNumber = LCI $ s.cellNumber.unLCI + 1})
  pure cell
  where
    resetIndent :: OrgParser a -> OrgParser a -- 63
    resetIndent p = do
      a <- p
      modify (\s -> s {continuationIndent = 0})
      pure a
    toggleFootnote :: Bool -> OrgParser a -> OrgParser a
    toggleFootnote b p = do
      a <- p
      modify (\s -> s {prevCellIsFootnote = b})
      pure a

The resetIndent wrapper 63 resets the continuationIndent attribute 31 in the data:OrgParserState back to 0; this information is useful for parsers that need to understand indentation precisely. See f:listItemsEndParser for how it's used by that function.

The f:listItemStartParser and f:listItemsEndParser are the only parsers that can consume leading indentation! The f:paragraphParser can consume leading indentation, but only on lines except the first one (so that it can ensure that paragraphs that are part of a list item can have all of its lines line up together). For example, the following paragraph is inside a list item

  - Hello
    world.

and will be parsed as a string Hello world and not as Hello world (the leading indent for world will be ignored).

Any other indentation should be treated as being invalid. To enforce this, we put in a f:detectInvalidIndentation parser which technically isn't a parser but which will throw an error if it is able to successfully parse indentation. The idea is that if none of the other parsers in the list of parsers in f:cellParser are able to consume indentation, then this will allow detectInvalidIndentation to see such indentation and fail the overall parser.

f:detectInvalidIndentation
detectInvalidIndentation :: OrgParser Cell
detectInvalidIndentation = do
  _ <- MP.hidden $ onlySpaces
  void
    $ MP.fancyFailure -- 64
    . Set.singleton
    . MP.ErrorFail
    $ "invalid indentation" -- 65
  pure $ CListItem [] -- 66
f:onlySpaces
onlySpaces :: OrgParser Text
onlySpaces = MP.takeWhile1P (Just "space") (== ' ') -- 67

We signal the error with invalid ... 65 so as to not shadow the typical unexpected ... language from Megaparsec's usual parse failures. Note also that the pure $ CListItem [] 66 is only there to satisfy the type checker --- we will always fail if the takeWhile1P 67 succeeds (the MP.fancyFailure 64 will not be executed if takeWhile1P fails).

The bracketBalance is set to 0 62 in f:cellParser after a cell is parsed, because we're not interested in cross-cell issues about unbalanced brackets. See f:failIfUnbalancedBrackets.

The rest of the top-level headings this document is mainly concerned with describing all of the different building blocks used for constructing cells.

3.5 Tests

These tests check for mostly empty documents, and also those with some metadata.

We should be able to parse empty input.

t:empty-input
it "empty input" $ do
  MP.runParser
    (runStateT L.orgDocParser defaultOrgParserState)
    "empty input"
    nilOidOrgDocPrefix
    `shouldParse` (L.defaultOrgDoc nilOid [], defaultOrgParserState)

The f:defaultOrgDoc is just a default (mostly blank) OrgDoc. It's useful for testing.

f:defaultOrgDoc
defaultOrgDoc :: OID -> [(Text, Text)] -> OrgDoc
defaultOrgDoc oid metas =
  OrgDoc
    { oid = oid,
      cells = [],
      pseudoEdges = [],
      footnoteRefs = M.empty,
      glossary = M.empty,
      lineLabels = M.empty,
      cslPath = Nothing,
      bibPaths = [],
      citations = [],
      meta = fromList metas'
    }
  where
    metas' =
      if oid == nilOid
        then ("ID", "00000000-0000-0000-0000-000000000000") : metas
        else metas

We should also be able to parse blank documents (not empty, but only has whitespace).

ti:0001-blankline
🎯 test/Lilac/ParseSpec/0001-blankline

We have to put in two newlines above because during parsing we will lose one newline (it is used to parse the "\n#+end_src" string; see f:someBTokensParser). This leaves us with just one newline in test/Lilac/ParseSpec/0001-blankline.

t:0001-blankline
shouldParseWith
  "0001-blankline"
  (L.defaultOrgDoc nilOid [])

Aside: ti:0001-blankline in HTML looks like there is no content (this is intentional), because f:zeroBTokensParser parses it the same way it parses a block with zero bytes in it (see ti:0007-blocks-empty).

Parsing comments should also result in a blank document.

ti:0001-comments-only
🎯 test/Lilac/ParseSpec/0001-comments-only
# This is a single-line comment.

#+begin_comment
This is inside
a block comment.
#+end_comment
t:0001-comments-only
shouldParseWith
  "0001-comments-only"
  (L.defaultOrgDoc nilOid [])

At the other end of the spectrum, Lilac should be able to parse itself.

t:self-parse
before (LU.readFrom "" "index.org") $ do
  it "self-parse" $ \input -> do
    MP.runParser
      (runStateT L.orgDocParser defaultOrgParserState)
      "index.org"
      `shouldSucceedOn` input

3.5.1 Document metadata

Below are some unit test cases for parsing metadata.

ti:0002-doc-metadata:basic
🎯 test/Lilac/ParseSpec/0002-doc-metadata
#+title: An Org Document
#+author: A. U. Thor
#+property: header-args+ :noweb no-export
#+auto_tangle: t
t:0002-doc-metadata:basic
shouldParseWith
  "0002-doc-metadata"
  $ L.defaultOrgDoc
    nilOid
    [ ("author", "A. U. Thor"),
      ("auto_tangle", "t"),
      ("property", "header-args+ :noweb no-export"),
      ("title", "An Org Document")
    ]

Note that we currently don't do any intelligent processing of the metadata fields. For example, the value of the property key above is a mess because it itself contains a key and value (:noweb and no-export).

Below is the same as the test above, but with some whitespace and comments sprinkled in between.

ti:0003-doc-metadata-with-comments
🎯 test/Lilac/ParseSpec/0003-doc-metadata-with-comments
#+title: An Org Document
# This is a comment line.
#+author: A. U. Thor
# This is another comment line.


# Note the whitespace above this line.
#+property: header-args+ :noweb no-export
# Below is a block comment.
#+begin_comment
Yet another comment.
#+end_comment
#+auto_tangle: t

# Last comment followed by some trailing newlines.



t:0003-doc-metadata-with-comments
shouldParseWith
  "0003-doc-metadata-with-comments"
  $ L.defaultOrgDoc
    nilOid
    [ ("author", "A. U. Thor"),
      ("auto_tangle", "t"),
      ("property", "header-args+ :noweb no-export"),
      ("title", "An Org Document")
    ]

Indentation is invalid for document metadata. Even single-line comments may not be indented.

ti:0008-indentation-comment
🎯 test/Lilac/ParseSpec/0008-indentation-comment
 # invalid comment (it's indented)
t:0008-indentation-comment
shouldFailWithMessage
  "0008-indentation-comment"
  "invalid indentation"
  1

Metadata values can have leading whitespace. We ignore such whitespace.

ti:0002-doc-metadata-whitespace-before-value
🎯 test/Lilac/ParseSpec/0002-doc-metadata-whitespace-before-value
#+title:             Parse me without leading whitespace
t:0002-doc-metadata-whitespace-before-value
shouldParseWith
  "0002-doc-metadata-whitespace-before-value"
  $ L.defaultOrgDoc
    nilOid
    [ ("title", "Parse me without leading whitespace")
    ]

Metadata values are required (there must be some non-whitespace text for it).

ti:0002-doc-metadata-missing-value
🎯 test/Lilac/ParseSpec/0002-doc-metadata-missing-value
#+title:
t:0002-doc-metadata-missing-value
shouldFailWithError
  "0002-doc-metadata-missing-value"
  ( utoks ":\n"
      <> elabel "(doc meta) separator"
      <> elabel "(doc meta) keyword letter"
  )
  7

Metadata can also come from top-level properties drawers.

ti:0002-doc-metadata-properties-drawer
🎯 test/Lilac/ParseSpec/0002-doc-metadata-properties-drawer
:PROPERTIES:
:ID:       7af1aa3e-41ca-4810-a001-2fdf10768f2f
:END:
t:0002-doc-metadata-properties-drawer
shouldParseWithState
  "0002-doc-metadata-properties-drawer"
  ( L.defaultOrgDoc
      (fromWords 0x7af1aa3e41ca4810 0xa0012fdf10768f2f)
      [ ("ID", "7af1aa3e-41ca-4810-a001-2fdf10768f2f")
      ]
  )
  defaultOrgParserState
    { docOid = fromWords 0x7af1aa3e41ca4810 0xa0012fdf10768f2f
    }

If the :ID: is missing, we assign a nil (all zero bits) OID. We reuse the same test case file (ti:0001-blankline) from t:0001-blankline.

t:0002-doc-metadata-properties-drawer-empty
shouldParseWithState
  "0001-blankline"
  (L.defaultOrgDoc nilOid [])
  defaultOrgParserState

Metadata from the top-level properties drawer and also the key/value metadata lines get combined together into a single map.

ti:0002-doc-metadata-properties-drawer-combine
🎯 test/Lilac/ParseSpec/0002-doc-metadata-properties-drawer-combine
:PROPERTIES:
:ID:       7af1aa3e-41ca-4810-a001-2fdf10768f2f
:END:
#+title: An Org Document
#+author: A. U. Thor
#+property: header-args+ :noweb no-export
t:0002-doc-metadata-properties-drawer-combine
shouldParseWithState
  "0002-doc-metadata-properties-drawer-combine"
  ( L.defaultOrgDoc
      (fromWords 0x7af1aa3e41ca4810 0xa0012fdf10768f2f)
      [ ("ID", "7af1aa3e-41ca-4810-a001-2fdf10768f2f"),
        ("author", "A. U. Thor"),
        ("property", "header-args+ :noweb no-export"),
        ("title", "An Org Document")
      ]
  )
  defaultOrgParserState
    { docOid = fromWords 0x7af1aa3e41ca4810 0xa0012fdf10768f2f
    }

If there is a clash, error out instead of silently choosing precedence.

ti:0002-doc-metadata-properties-drawer-clash
🎯 test/Lilac/ParseSpec/0002-doc-metadata-properties-drawer-clash
:PROPERTIES:
:ID:       00000000-0000-0000-0000-000000000000
:foo:      bar
:END:
#+title: An Org Document
#+author: A. U. Thor
#+property: header-args+ :noweb no-export
#+foo: xyz
t:0002-doc-metadata-properties-drawer-clash
before
  ( LU.readFrom
      "test/Lilac/ParseSpec/"
      "0002-doc-metadata-properties-drawer-clash"
  )
  $ do
    it "0002-doc-metadata-properties-drawer-clash" $ \input -> do
      MP.runParser
        (runStateT L.orgDocParser defaultOrgParserState)
        "0002-doc-metadata-properties-drawer-clash"
        input
        `shouldFailWith` ( errFancy 181
                             . fancy
                             $ MP.ErrorFail
                               ( "conflicting keys in the top-level PROPERTIES"
                                   <> " drawer and document metadata"
                                   <> " key/values: [\"foo\"]"
                               )
                         )

4 Styled text

A data:StyledText is just some text that has one or more styles attached to it. Here are some examples: bold, italic, underline, verbatim, and code.

data:StyledText
type StyledText :: Type
data StyledText = StyledText
  { body :: Text,
    style :: StyleMask
  }
  deriving stock (Eq, Generic, Ord, Show)
  deriving anyclass (FromJSON, ToJSON)

The newtype:StyleMask is a low-level bit mask that can store multiple boolean values of each of the supported data:Style bits. If the string has no style, then it's not stylized at all and the mask will be 0 (no bits set). See StyleMask details for a discussion of how this data structure is used and transformed into a list of data:Style values. For now, we can just pretend that the StyleMask is like a list of data:Style values.

In order to save some keystrokes (especially in test cases), we use a couple functions, f:stext and f:ptext.

f:stext
stext :: Text -> [Style] -> StyledText
stext body styles = StyledText {body = body, style = styleMaskFrom styles}
f:ptext
ptext :: Text -> [Style] -> PToken
ptext body styles = PTokenStyledText $ stext body styles

The data:Style type is straightforward enough. For now we don't support all possible styles supported by Orgmode (such as strike-through text), for both simplicity but also prescriptive reasons. For example, strike-through text should never be needed during exposition.

data:Style
type Style :: Type
data Style
  = Bold
  | Italic
  | Underline
  | Verbatim
  | Code
  | Smallcaps
  | Subscript
  | Superscript
  deriving stock (Enum, Eq, Generic, Ord, Show)
  deriving anyclass (FromJSON, ToJSON)

The f:styleMarkers keeps track of the characters that activate and deactivate each style. It has the syntactic characters we use to mark text with the a supported style.

We don't actually allow users to use the Smallcaps, Subscript, or Superscript styles themselves (this is why they're missing from f:styleMarkers); these are used inside f:cslJsonTextToProse and f:weaveStyledText to preserve these styles that CslJson might generate for citation processing.

f:styleMarkers
styleMarkers :: Map Char Style
styleMarkers =
  fromList
    [ ('*', Bold),
      ('/', Italic),
      ('_', Underline),
      ('=', Verbatim),
      ('~', Code)
    ]

Characters in the f:styleMarkers can be escaped with a backslash. For example, the strings 1\*2 and a\_b are interpreted as 1*2 and a_b, because they act to escape the style markers * and _, respectively. If you want to write a literal backslash, you must use \. This use of the backslash as an escape character deviates from Orgmode.

This rule also applies for slashes, such as 1\/2 in ti:0005-styled-strings-escape-marker. However, for slashes there is a simpler (and arguably more intuitive syntax) where slashes inside a word are not treated as style markers, so they don't need to be escaped. See f:slashSeparatedWords and ti:0005-styled-strings-slash-separated-words.

ti:0005-styled-strings-escape-marker
🎯 test/Lilac/ParseSpec/0005-styled-strings-escape-marker
/foo and\/or 1\/2 bar \\ a\\b/
t:0005-styled-strings-escape-marker
shouldParseWith
  "0005-styled-strings-escape-marker"
  $ nilOrgDoc
    [ CParagraph
        $ Prose
          Expected prose for 0005-styled-strings-escape-marker
    ]
    []
Expected prose for 0005-styled-strings-escape-marker
[ ptext "foo and/or 1/2 bar \\ a\\b" [Italic]
]

4.1 Parsing

Parsing styled strings is somewhat tricky, because of two main problems:

  1. the style markers use the same character for both activation and deactivation, and

  2. the styles can be nested or interleaved.

You can have, for example, lines that are italicized like this but also have bold text inside of it.

We approach these problems with a simple idea: use a bit mask (newtype:StyleMask) to keep track of markers. Every time we see a starting marker, we turn on the bit in the mask to activate that style (if it's off), or turn it off (if it's on), by calling f:styleMaskFlip.

If the input is such that the markers are not balanced, such that by the time we finish parsing a paragraph (or end of a heading) and the mask still has active styles in it, we throw an error with f:failIfLeftoverActiveStyles. That is, we expect style starting and ending markers to always be balanced by the end of every paragraph or heading. This deviates from vanilla Org, which does not have this restriction.

f:failIfLeftoverActiveStyles
failIfLeftoverActiveStyles :: OrgParser ()
failIfLeftoverActiveStyles = do
  pState <- get
  let (StyleMask mask) = pState.styleMask
  when (mask /= 0) $ do
    fail
      $ "leftover active styles: "
      <> (show $ maskToStyles $ StyleMask mask)

We don't really care which styles are turned on or off at which point (the style markers can be used at the beginning, middle, or end of a word --- where a word is any sequence of non-whitespace characters). The only thing we check is that by the end of the cell (heading or paragraph), all styles must be turned off.

ti:0005-styled-strings-messy-boundaries
🎯 test/Lilac/ParseSpec/0005-styled-strings-messy-boundaries
* *foo *bar baz a*b*c (*ok*) **yes
t:0005-styled-strings-messy-boundaries
shouldParseWithState
  "0005-styled-strings-messy-boundaries"
  ( nilOrgDoc
      [ CHeading
          Heading
            { level = 1,
              section = [1],
              body =
                Prose
                  Expected prose for 0005-styled-strings-messy-boundaries,
              meta = fromList []
            }
      ]
      []
  )
  defaultOrgParserState
    { headingOidStack = [Nothing],
      headingLevel = 1,
      headingSectionNumber = [1]
    }
Expected prose for 0005-styled-strings-messy-boundaries
[ ptext "foo " [Bold],
  ptext "bar baz a" [],
  ptext "b" [Bold],
  ptext "c (" [],
  ptext "ok" [Bold],
  ptext ") yes" []
]

In ti:0005-styled-strings-leftover-active-styles we keep parsing to the end of the heading, expecting to see the ending style marker for bold text, but never see it. So the failure from f:failIfLeftoverActiveStyles gets triggered.

ti:0005-styled-strings-leftover-active-styles
🎯 test/Lilac/ParseSpec/0005-styled-strings-leftover-active-styles
* foo *bar baz
t:0005-styled-strings-leftover-active-styles
shouldFailWithMessage
  "0005-styled-strings-leftover-active-styles"
  "leftover active styles: [Bold]"
  15

Note that as soon as we see a style marker we stylize the text at hand; the StyleMask is "strict" in this sense. This way, we can start parsing stylized text without needing to see a corresponding ending marker. This imperative style is easy to think about.

The algorithm for parsing data:StyledText requires parsing style markers (and only style markers), or parsing other text that will be affected by style markers. Let's first look at the simpler case, that of parsing style markers.

f:styleMarkerParser
styleMarkerParser :: OrgParser StyledText
styleMarkerParser = do
  pState <- get

  let mask = pState.styleMask
  c <- MP.satisfy $ isStyleMarker mask
  styleMask <- toStyleMask c

  let newMask = styleMaskFlip mask styleMask -- 68
  put pState {styleMask = newMask}

  pure
    StyledText
      { body = "",
        style = newMask
      }

All we do is parse a single style marker character, and flip the bit for it as either on or off, with f:styleMaskFlip 68. Ideally we wouldn't even bother returning anything at the end of f:styleMarkerParser (because we've updated the pState and that's the only essential thing we must do). But because of the way it is used in f:proseParser, we have to return a StyledText. We return an empty body field though, so that we can filter it out with the nonEmpty helper in f:proseParser.

We cannot parse multiple style markers in one go, because f:isStyleMarker changes behavior depending on the current active style mask. If we are in a verbatim or code style, we ignore all other style markers except for those that will end the current (verbatim or code) style. Otherwise, we won't be able to write stuff like * because we would think that the asterisk in there is starting a bold style (which it is not because it is surrounded by ~ to mark code).

f:isStyleMarker
isStyleMarker :: StyleMask -> Char -> Bool
isStyleMarker styleMask c
  | isVerbatim styleMask = c == '='
  | isCode styleMask = c == '~'
  | otherwise = member c styleMarkers

Parsing actual styled text requires reading the state of the StyleMask, and then reading some text. This is done in f:styledTextParser. It delegates essentially all of the work to f:textParser.

f:styledTextParser
styledTextParser :: OrgParser StyledText
styledTextParser = do
  pState <- get

  let mask = pState.styleMask
  text <- textParser mask

  pure
    StyledText
      { body = text,
        style = mask
      }

The f:textParser is careful not to step on other parsers' toes, because in a sense it is the most "greedy" parser, as it can parse many different types of input.

f:textParser
textParser :: StyleMask -> OrgParser Text
textParser styleMask
  | isVerbatim styleMask || isCode styleMask = do
      t <- verbatimCodeParser styleMask
      pure $ T.map newlineToSpace t -- 69
  | otherwise = do
      o <- MP.getOffset
      word <-
        MP.choice
          [ MP.try $ slashSeparatedWords,
            MP.label "normal characters" normalChars,
            escapedSymbolParser styleMask -- 70
          ]
      pState <- get
      let haveNotSeenGlossaryTermDelimiterYet =
            pState.insideParagraph && pState.glossaryTermOffset == 0
          wordIsGlossaryTermDelimiter = word == "::" && isEmptyMask styleMask
      when
        ( haveNotSeenGlossaryTermDelimiterYet
            && wordIsGlossaryTermDelimiter
        )
        $ do
          modify (\s -> s {glossaryTermOffset = o}) -- 71
      pure word
  where
    newlineToSpace c = if c == '\n' then ' ' else c
    normalChars :: OrgParser Text
    normalChars = do
      cs <- MP.some $ normalChar
      pure $ T.pack cs
    normalChar :: OrgParser Char
    normalChar = do
      pState <- get
      let toAvoid =
            [ (pState.insideCitation, string ";"),
              (pState.insideCitePrefix, citeIdParser),
              ( pState.insideCitation
                  && (pState.bracketBalance == 0),
                string "]"
              ),
              (pState.insideLinkDisplayName, string "]]"),
              ( not pState.insideCitation
                  && not pState.insideCitePrefix
                  && not pState.insideLinkDisplayName,
                string "[["
              )
            ]
      forM_
        toAvoid
        $ \(condition, p) -> when condition $ MP.notFollowedBy p
      c <- MP.satisfy isNormalChar
      let bracketBalanceDiff
            | c == '[' = 1
            | c == ']' = -1
            | otherwise = 0
      modify (\s -> s {bracketBalance = s.bracketBalance + bracketBalanceDiff})
      pure c
    isNormalChar c =
      notMember c styleMarkers
        && (not $ isSpace c)
        && (not $ c == '\\')

For the verbatim and code styles, we replace newlines with spaces with calls to newlineToSpace 69, because in practice newlines can get inserted only because we're wrapping the source text (in Org mode) at 80 columns. If the user wants to put in a piece of multiline text with specific newlines, they should just use a source code block instead of an inline verbatim or code style.

The verbatim and code styles rely on f:verbatimCodeParser to parse text inside those styles. We support escaping the closing style marker with a backslash \, because this is the only way to allow using the styles with the closing markers. That is, if we just did a simple

MP.takeWhile1P (Just "code") (\c -> c /= '~')

then there would be no way to write something like

~~~

to have ~ (tilde) inside a code style. You'd have to write

=~=

to enclose the tilde inside a verbatim style. However, because of 72, we can write

~\~~

to stylize a single tilde with the code style. The disadvantage is that we have to always escape a backslash \ if we want just a single backslash without doing any escaping.

f:verbatimCodeParser
verbatimCodeParser :: StyleMask -> OrgParser Text
verbatimCodeParser styleMask = do
  parts <-
    MP.some
      $ MP.choice
        [ escapedSymbol, -- 72
          MP.takeWhile1P Nothing isNotClosingOrEscape
        ]
  pure $ T.concat parts
  where
    escapedSymbol :: OrgParser Text
    escapedSymbol = do
      _ <- char '\\'
      c <- MP.satisfy (\c -> not $ isSpace c)
      if
        | isVerbatim styleMask && c `elem` ['=', '\\'] -> pure $ T.singleton c
        | isCode styleMask && c `elem` ['~', '\\'] -> pure $ T.singleton c
        | otherwise -> pure $ "\\" <> T.singleton c
    isNotClosingOrEscape c
      | isVerbatim styleMask = c /= '=' && c /= '\\'
      | otherwise = c /= '~' && c /= '\\'

Back to f:textParser. We take special care to escape style markers with f:escapedSymbolParser 70.

f:escapedSymbolParser
escapedSymbolParser :: StyleMask -> OrgParser Text
escapedSymbolParser styleMask = do
  _ <- char '\\'
  s <-
    MP.choice
      [ do
          c <- MP.satisfy (\c -> isStyleMarker styleMask c || c == '\\')
          pure $ T.singleton c,
        string "n",
        string "vert{}"
      ]
  pure $ case s of
    "vert{}" -> "|"
    "n" -> "\n"
    _ -> s

f:escapedSymbolParser also lets users insert a newline character with \n. This way, you can write multiline text inside a table, even if the table is not taking up the full width of the HTML page (at which point the browser will insert soft newlines on its own).

The word \vert{} can be used to escape a pipe character inside table cells, because although our Megaparsec-powered parser is powerful enough to handle escaping pipe characters with \| (for consistency with \/ to escape the italic style marker), Orgmode is unable to deal with this in Emacs (so pressing TAB will mess up the alignment of table cells). For this reason, we use \vert{} instead. Of course, outside of tables the pipe character can be written raw like this "|" without issue.

There is one exception to the "style markers must always be balanced" rule, and it's what we call "slash-separated words". These are cases where we see one or more slash characters inside an alphanumeric word, such as and/or, or a plain (non-math) fraction like 1/2, or even something like 1/2/3. In these cases, we don't want the slashes to be treated as italic style markers.

f:slashSeparatedWords
slashSeparatedWords :: OrgParser Text
slashSeparatedWords = do
  let wordParser :: OrgParser Text
      wordParser = do
        _ <- cannotStartWithClosingPunc
        MP.takeWhile1P (Just "slash-separated words") isWordChar
  firstWord <- wordParser
  remainingWords <-
    MP.some $ do
      _ <- MP.notFollowedBy $ MP.choice [string "/ ", string "/\n"]
      _ <- char '/'
      word <- wordParser
      pure $ "/" <> word
  when (lastWordIsMeaningless $ reverse remainingWords)
    $ fail
      """
      last word is not meaningful; unlikely to be
      intended as a slash-separated-word
      """
  let allWords = firstWord : remainingWords
  when (hasLinkDelimiter allWords)
    $ fail
      """
      has link delimiter \"[[\"; unlikely to be
      intended as a slash-separated-word
      """
  pure $ T.concat allWords
  where
    cannotStartWithClosingPunc :: OrgParser ()
    cannotStartWithClosingPunc =
      MP.notFollowedBy
        $ MP.choice
          [ string "]",
            string "}",
            string ")",
            string "]]"
          ]
    isWordChar c = not (isSpace c) && c `notElem` (keys styleMarkers <> "\\")
    lastWordIsMeaningless :: [Text] -> Bool
    lastWordIsMeaningless = \case
      [] -> True
      (lastWord : [])
        | lastWord `elem` ["/.", "/,", "/:"] -> True
        | otherwise -> False
      _ -> False
    hasLinkDelimiter :: [Text] -> Bool
    hasLinkDelimiter = or . map ("[[" `T.isInfixOf`)

The rules inside f:slashSeparatedWords are a "best effort" attempt, not something that is always correct. For the cases where we need more flexibility, you can use escaped slashes like in ti:0005-styled-strings-escape-marker.

See ti:0005-styled-strings-slash-separated-words for some real-world examples.

4.2 StyleMask details

The previous section explained how we parse styled strings without getting into the implementation behind the newtype:StyleMask. We now get into these implementation details.

The StyleMask is an 8-bit word, which can hold boolean values for the 6 different Style data constructors, e.g. Bold, Italic, etc. It's a space-efficient type to replace an equivalent map or list of [Style] values. We use a newtype around a Word8 (an 8-bit word) so as to keep it distinct from Word8 itself.

newtype:StyleMask
type StyleMask :: Type
newtype StyleMask = StyleMask Word8
  deriving stock (Eq, Generic, Ord)
  deriving anyclass (FromJSON, ToJSON)

We have a custom Show instance because the default Show would just treat the Word8 as a number. Instead we want to show what the binary bits (turned on inside the Word8) would represent in terms of actual styles. We have to import Prelude because of the way Relude redefines show (and we need to use the show that Prelude uses; see this related discussion for the Basic Prelude library, a Relude alternative, for reference).

instance:Show Stylemask
instance Show StyleMask where
  show (StyleMask a) = "styleMaskFrom " <> x
    where
      x =
        Prelude.show
          . catMaybes
          . map
            ( \(i, b) ->
                if b
                  then (Just $ (toEnum i :: Style))
                  else Nothing
            )
          . zip [0 ..]
          $ toListLE a

The shown text string is prefixed with f:styleMaskFrom because that function is used as a constructor function to create a StyleMask easily from scratch. This follows the tradition used in Data.Map where they also use a constructor function (fromList) when show-ing a Map data structure.

f:styleMaskFrom
styleMaskFrom :: [Style] -> StyleMask
styleMaskFrom ss = StyleMask $ foldl' f (0 :: Word8) ss
  where
    f :: Word8 -> Style -> Word8
    f acc s = setBit acc (fromEnum s)

Similarly, we sometimes want to go the other direction --- that is, deconstruct a StyleMask into [Style]. So we have f:maskToStyles.

f:maskToStyles
maskToStyles :: StyleMask -> [Style]
maskToStyles (StyleMask mask) = foldl' f [] $ zip [0 ..] $ toListLE mask
  where
    f :: [Style] -> (Int, Bool) -> [Style]
    f acc (idx, b) =
      if b
        then (toEnum idx) : acc
        else acc

We have a helper function f:toStyleMask to convert Char (a style marker character) into a StyleMask. This is used to construct a style mask from a style marker in the input Org doc. The toStyleMask function runs inside a MonadFail monad, because the Char type could hold a value that is not a valid style marker. The use of MonadFail is seamless inside ParsecT (the monad we use inside OrgParser), because ParsecT has a MonadFail instance.

f:toStyleMask
toStyleMask :: (MonadFail m) => Char -> m StyleMask
toStyleMask cs = pure =<< styleToStyleMask =<< charToStyle cs

charToStyle :: (MonadFail m) => Char -> m Style
charToStyle c = case lookup c styleMarkers of
  Just style -> pure style
  Nothing -> fail $ "programmer error: unrecognized style marker " <> (show c)

styleToStyleMask :: (MonadFail m) => Style -> m StyleMask
styleToStyleMask s = pure $ StyleMask (setBit 0 $ fromEnum s)

f:styleMaskFlip is a helper to perform a binary XOR operation on two StyleMask objects, so that callers don't have to perform the deconstruction and reconstruction of the underlying Word8 primitive themselves. This operation is used to either turn on bits, or turn off bits if they're already on.

f:styleMaskFlip
styleMaskFlip :: StyleMask -> StyleMask -> StyleMask
styleMaskFlip (StyleMask a) (StyleMask b) = StyleMask $ a .^. b

We also have some helpers to check whether particular styles are active.

f:isVerbatim
isVerbatim :: StyleMask -> Bool
isVerbatim (StyleMask a) = testBit a (fromEnum Verbatim)
f:isCode
isCode :: StyleMask -> Bool
isCode (StyleMask a) = testBit a (fromEnum Code)

f:isEmptyMask checks whether any styles are active.

f:isEmptyMask
isEmptyMask :: StyleMask -> Bool
isEmptyMask (StyleMask a) = a == 0

We stated earlier around t:0005-styled-strings-leftover-active-styles that we don't have to wait to see the ending style marker to start parsing styled text. While this is true, the downside is that by the time we construct a StyledText, we have no knowledge of future StyledText objects that have not yet been parsed. This can lead to a proliferation of StyledText objects that all share the same style, but are still parsed into separate PToken objects. That is, instead of just one PToken with a single StyledText object with a sizable body field, we could end up with [PToken] where each of the elements has a small body, but with the same overall style.

Obviously, having a list of PToken objects when we can just have a single one is problematic, if not wasteful. We want to join up sequences of styled text that share the same style, and make them use a single StyledText object. For this purpose, there is f:compressProse.

f:compressProse
compressProse :: Prose -> Prose
compressProse prose =
  Prose
    . reverse
    . dropUnnecessary -- 73
    $ foldl' f [] prose.unProse
  where
    f :: [PToken] -> PToken -> [PToken]
    f [] token = token : []
    f
      ((t1@(PTokenStyledText (StyledText body1 style1))) : rest)
      t2@(PTokenStyledText (StyledText body2 style2))
        | style1 == style2 =
            (PTokenStyledText $ StyledText (T.concat [body1, body2]) style1)
              : rest
        | otherwise = t2 : t1 : rest
    f acc token = token : acc
    dropUnnecessary [] = []
    dropUnnecessary (((PTokenStyledText (StyledText " " _))) : rest) = rest
    dropUnnecessary (((PTokenStyledText (StyledText "" _))) : rest) = rest
    dropUnnecessary tokens = tokens

This function works by looking at two consecutive PTokenStyledText elements at a time (one that's already in the accumulator (acc) from the previous iteration and one from the current token at hand), joining them if they share the same style.

f:compressProse also eliminates trailing whitespace, with dropUnnecessary 73. Trailing whitespace can occur if the last word of a paragraph at the end of the file ends with an active StyleMask followed by a newline (trailing whitespace between paragraphs can't occur because the lexeme in f:cellParser will skip past them). For example, if the input text looks like

_foo
bar_

then the last underscore can be followed by a newline (assuming the editor used to edit this file is UNIX-friendly and appends a trailing newline), in which case that newline would get parsed by the spaceConsumer in f:paragraphParser as its own separate StyledText object as a single space character. We want to eliminate these objects because they don't add any useful information for Lilac itself, and so we use dropUnnecessary above to filter them out.

dropUnnecessary also removes tokens with an empty string as the StyledText object. This can occur when we parse trailing newlines at the end of the line even without style markers, from f:whitespaceCompressor.

Here we test how we minimize the number of PToken objects; although the individual letters are spaced out with multiple spaces, we only use two PToken objects (one for the plain text and another for the bold text) and each of these objects have just single spaces between the letters.

ti:0005-styled-strings-compression
🎯 test/Lilac/ParseSpec/0005-styled-strings-compression
a b  c   d    e      *f g  h   i    j*
t:0005-styled-strings-compression
shouldParseWith
  "0005-styled-strings-compression"
  $ nilOrgDoc
    [ CParagraph
        $ Prose
          Expected prose for 0005-styled-strings-compression
    ]
    []
Expected prose for 0005-styled-strings-compression
[ ptext "a b c d e " [],
  ptext "f g h i j" [Bold]
]

f:compressStyledText is like f:compressProse, but only for data:StyledText. It's used in f:cslJsonTextToProse to cut down on the verbosity of StyledText items generated by the conversion of CslJson to Prose.

f:compressStyledText
compressStyledText :: [StyledText] -> [StyledText]
compressStyledText = reverse . dropUnnecessary . foldl' f []
  where
    f :: [StyledText] -> StyledText -> [StyledText]
    f [] styledText = styledText : []
    f
      ((t1@(StyledText body1 style1)) : rest)
      t2@(StyledText body2 style2)
        | style1 == style2 =
            (StyledText (T.concat [body1, body2]) style1) : rest
        | otherwise = t2 : t1 : rest
    dropUnnecessary [] = []
    dropUnnecessary ((StyledText " " _) : rest) = rest
    dropUnnecessary ((StyledText "" _) : rest) = rest
    dropUnnecessary styledTexts = styledTexts

4.3 Additional tests

The simple case with no style markers.

ti:0005-styled-strings-no-style
🎯 test/Lilac/ParseSpec/0005-styled-strings-no-style
foo
t:0005-styled-strings-no-style
shouldParseWith
  "0005-styled-strings-no-style"
  $ nilOrgDoc
    [ plainParagraph "foo"
    ]
    []

The next case tests a simple case where one word is in bold.

ti:0005-styled-strings-bold
🎯 test/Lilac/ParseSpec/0005-styled-strings-bold
foo *bar* baz
t:0005-styled-strings-bold
shouldParseWith
  "0005-styled-strings-bold"
  $ nilOrgDoc
    [ CParagraph
        $ Prose
          [ ptext "foo " [],
            ptext "bar" [Bold],
            ptext " baz" []
          ]
    ]
    []

Below we mix things up a bit more, using more styles.

ti:0005-styled-strings-complex
🎯 test/Lilac/ParseSpec/0005-styled-strings-complex
*foo /bar _underlined  bold italic_/ baz* =verbatim  text= ~x  y  z~ *bold*
t:0005-styled-strings-complex
shouldParseWith
  "0005-styled-strings-complex"
  $ nilOrgDoc
    [ CParagraph
        $ Prose
          Expected prose for 0005-styled-strings-complex
    ]
    []
Expected prose for 0005-styled-strings-complex
[ ptext "foo " [Bold],
  ptext "bar " [Bold, Italic],
  ptext "underlined bold italic" [Bold, Italic, Underline],
  ptext " baz" [Bold],
  ptext " " [],
  ptext "verbatim  text" [Verbatim],
  ptext " " [],
  ptext "x  y  z" [Code],
  ptext " " [],
  ptext "bold" [Bold]
]

Here's another test, which checks some edge cases when dealing with punctuation.

ti:0005-styled-strings-complex-punctuation
🎯 test/Lilac/ParseSpec/0005-styled-strings-complex-punctuation
(foo =bar=). What's up? and *that!* /(italic)/. It's #1!
t:0005-styled-strings-complex-punctuation
shouldParseWith
  "0005-styled-strings-complex-punctuation"
  $ nilOrgDoc
    [ CParagraph
        $ Prose
          Expected prose for 0005-styled-strings-complex-punctuation
    ]
    []

Note how we preserve the information of having no space character between the bar and the closing parenthesis ")".

Expected prose for 0005-styled-strings-complex-punctuation
[ ptext "(foo " [],
  ptext "bar" [Verbatim],
  ptext "). What's up? and " [],
  ptext "that!" [Bold],
  ptext " " [],
  ptext "(italic)" [Italic],
  ptext ". It's #1!" []
]

Check that we can parse slashes inside words as slashes, not as italic style markers.

ti:0005-styled-strings-slash-separated-words
🎯 test/Lilac/ParseSpec/0005-styled-strings-slash-separated-words
/italic fraction 1/2/ foo and/or bar /A+/B-/ +1/+2. key/value
t:0005-styled-strings-slash-separated-words
shouldParseWith
  "0005-styled-strings-slash-separated-words"
  $ nilOrgDoc
    [ CParagraph
        $ Prose
          Expected prose for 0005-styled-strings-slash-separated-words
    ]
    []
Expected prose for 0005-styled-strings-slash-separated-words
[ ptext "italic fraction 1/2" [Italic],
  ptext " foo and/or bar " [],
  ptext "A+/B-" [Italic],
  ptext " +1/+2. key/value" []
]

This is another test, involving the Verbatim style. It checks the case where we have a style marker inside a word (not at the usual beginning or end of a word).

ti:0005-styled-strings-verbatim
🎯 test/Lilac/ParseSpec/0005-styled-strings-verbatim
(=import ./.=) hello
t:0005-styled-strings-verbatim
shouldParseWith
  "0005-styled-strings-verbatim"
  $ nilOrgDoc
    [ CParagraph
        $ Prose
          Expected prose for 0005-styled-strings-verbatim
    ]
    []
Expected prose for 0005-styled-strings-verbatim
[ ptext "(" [],
  ptext "import ./." [Verbatim],
  ptext ") hello" []
]

This is another test to check that the bold style marker (asterisk) is ignored if inside a verbatim or code marker.

ti:0005-styled-strings-verbatim-code-ignore-immediate-bold
🎯 test/Lilac/ParseSpec/0005-styled-strings-verbatim-code-ignore-immediate-bold
=*a= =b*= ~*c~ ~d*~
t:0005-styled-strings-verbatim-code-ignore-immediate-bold
shouldParseWith
  "0005-styled-strings-verbatim-code-ignore-immediate-bold"
  $ nilOrgDoc
    [ CParagraph
        $ Prose
          Expected prose for 0005-styled-strings-verbatim-code-ignore-immediate-bold
    ]
    []
Expected prose for 0005-styled-strings-verbatim-code-ignore-immediate-bold
[ ptext "*a" [Verbatim],
  ptext " " [],
  ptext "b*" [Verbatim],
  ptext " " [],
  ptext "*c" [Code],
  ptext " " [],
  ptext "d*" [Code]
]

Verbatim and code styles take precedence over links. So even if you have text that looks like a link, they will not be parsed as such if surrounded by verbatim or code styles. The same applies for inline math, whose parser should not apply if we're inside a verbatim (or code) region.

ti:0005-styled-strings-verbatim-precedence
🎯 test/Lilac/ParseSpec/0005-styled-strings-verbatim-precedence
=[[foo=

[[hello]] there

=\(1 + 1 = ~\(2 * 2\)~

~[[bar]]~
t:0005-styled-strings-verbatim-precedence
shouldParseWith
  "0005-styled-strings-verbatim-precedence"
  $ nilOrgDoc
    Expected prose for 0005-styled-strings-verbatim-precedence
    [PseudoEdge (LCI 1, LinkOid {oid = fromWords 0 0, name = "hello"})]
Expected prose for 0005-styled-strings-verbatim-precedence
[ CParagraph
    $ Prose
      [ ptext "[[foo" [Verbatim]
      ],
  CParagraph
    $ Prose
      [ PTokenLink
          Link
            { target =
                LinkTargetOid
                  LinkOid
                    { oid = nilOid,
                      name = "hello"
                    },
              name =
                [ stext "hello" []
                ]
            },
        ptext " there" []
      ],
  CParagraph
    $ Prose
      [ ptext "\\(1 + 1 " [Verbatim],
        ptext " " [],
        ptext "\\(2 * 2\\)" [Code]
      ],
  CParagraph
    $ Prose
      [ ptext "[[bar]]" [Code]
      ]
]

ti:0005-styled-strings-escaping checks how we handle symbol escaping inside verbatim and code styles (compare against f:verbatimCodeParser).

ti:0005-styled-strings-escaping
🎯 test/Lilac/ParseSpec/0005-styled-strings-escaping
=\\= =\== =\(= ~\\~ ~\~~ ~\(~
t:0005-styled-strings-escaping
shouldParseWith
  "0005-styled-strings-escaping"
  $ nilOrgDoc
    Expected prose for 0005-styled-strings-escaping
    []
Expected prose for 0005-styled-strings-escaping
[ CParagraph
    $ Prose
      [ ptext "\\" [Verbatim],
        ptext " " [],
        ptext "=" [Verbatim],
        ptext " " [],
        ptext "\\(" [Verbatim],
        ptext " " [],
        ptext "\\" [Code],
        ptext " " [],
        ptext "~" [Code],
        ptext " " [],
        ptext "\\(" [Code]
      ]
]

5.1 OIDs and Emacs

Lilac relies on Org files that are written in Emacs (as it is the best environment for writing Org files). When the org-id-locations-file and org-id-track-globally variables are set, Emacs remembers which files have which Org IDs (where the "ID" is the OID generated by org-id-get-create and org-id-store-link). Emacs then saves this information to the value of org-id-locations-file.

Historically Lilac used to parse these files generated by Emacs, but it has moved on to a compilation pipeline that doesn't need to rely on these files coming from Emacs. If Lilac wants to bring back tangling and weaving based purely on Org files without the compilation process, it will be forced to read these OID location files again. Below is a brief discussion of the format of these files to serve as a historical reference.

The syntax is of a list of list of strings, in Emacs lisp. The inner lists have the relative file path of the Org file as the first element, followed by the OIDs. Each Org ID is just a UUIDv4 string. The OIDs are in reverse order (probably because Emacs grows the list right-to-left, just like Haskell's : (aka "cons") function).

Below is an example, with newlines inserted for better presentation (Emacs does not use newlines here, and so in the wild everything is in one line):

(("note/raw/recreation.org" "a1e6d0d5-4540-423b-864e-b96ad06c56eb")
 ("../prog/lilac/index.org" "3c576a02-418b-431b-ab96-e4936e977fb7"
                            "ce9af5ef-6559-4ffb-82c1-a439214eda6d"
                            "8f5752c0-6162-4de6-9ceb-f78ba584722c"
                            "b449a097-d702-4fd6-98de-cb81f7e49177"))

The information stored in this file is global, because Emacs will update it whenever it encounters Org files.

5.3.1 OID references

The OID reference uniquely identifies the linked item. Below are some examples:

[[foo]]

[[id:62a7d69a-7aa7-4607-b93e-7ed382a5f256][link display name]]

[[id:62a7d69a-7aa7-4607-b93e-7ed382a5f256::target name][link display name]]

The first one, [[foo]], is called an implicit link; it links to some other cell (block, table, figure, etc) within the current document, without (explicitly) specifying the OID of the current document. All documents must have an OID, and it is parsed in f:docMetaParser. Implicit links only specify the target (in this example, foo.

The second one, [[id:62a7d69a-7aa7-4607-b93e-7ed382a5f256][link display name]], refers to an OID explicitly. The OID could be either a document itself (the 1) or the OID of a heading (the latter is much more common in practice). Note that these OIDs can be any OID, not just from the current document. This example also has a link display name as the text to display to the user when we weave this to HTML. These link display names are parsed separately in f:linkDisplayNameParser (Link display names).

The third example has a target name in it. This name may or may not have newlines in it.

For all three examples, the part in the first set of brackets (foo, id:62a7d69a-7aa7-4607-b93e-7ed382a5f256, and id:62a7d69a-7aa7-4607-b93e-7ed382a5f256::name of target item) are all parsed by f:oidReferenceParser.

f:oidReferenceParser
oidReferenceParser :: [Char] -> [Char] -> OrgParser (OID, Text)
oidReferenceParser disallowedChars ending =
  MP.choice
    [ MP.try explicitOIDandTargetName,
      MP.try explicitOID,
      MP.try implicitOIDandExplicitTargetName
    ]
  where
    implicitOIDandExplicitTargetName :: OrgParser (OID, Text)
    implicitOIDandExplicitTargetName = do
      pState <- get
      name <- targetName
      pure (pState.docOid, name) -- 80
    explicitOID = do
      _ <- string "id:" -- 81
      oidText <-
        MP.some $ do
          _ <- MP.notFollowedBy . string $ T.pack ending
          MP.noneOf $ ':' : disallowedChars
      oid <- validateOidText $ T.pack oidText
      pure (oid, "")
    explicitOIDandTargetName = do
      (oid, _) <- explicitOID
      _ <- string "::"
      name <- targetName
      pure (oid, name)
    targetName :: OrgParser Text
    targetName = compressedTextParser disallowedChars ending -- 82

The implicit OID links get their OID assigned for the current document through the pState.docOid 80, and use targetName 82 to parse the name of the target. We cannot use the OID of the closest heading (like block.parentOid for blocks 120) here, because for an internal Noweb link we would almost always be referring to another different heading in the doc which would most likely have a different OID than the parentOid. So the best one to use is docOid. See f:buildGraph for how the docOid (aka document's OID) is used for generating a graph for cycle detection.

For links with an explicit OID, the OID is prefixed with id: 81.

The parsing of the target name is a little bit tricky, because sometimes the user may want to break long lines with whitespace. This is why f:compressedTextParser is more lenient about how whitespace is interpreted, so that if the user wants to refer to a target that has spaces in it, they can write that target over multiple lines. That is, a user may refer to a target name like foo bar baz in the following ways:

[[foo bar
baz]]

[[foo
bar
baz]]

[[foo
bar baz]]

All of the above links refer to the same foo bar baz named target in the current document, even though they are written slightly differently.

f:compressedTextParser
compressedTextParser :: [Char] -> [Char] -> OrgParser Text
compressedTextParser disallowedChars ending = do
  pState <- get
  tn <-
    proseParser'
      $ proseSpaceConsumer'
        pState.insideCodeBlock
  pure $ compressWhitespace tn
  where
    compressWhitespace = T.unwords . T.words
    proseSpaceConsumer' :: Bool -> OrgParser Text -- 83
    proseSpaceConsumer' insideCodeBlock
      | insideCodeBlock = onlySpaces
      | otherwise =
          MP.choice
            [ string "\n",
              onlySpaces
            ]
    proseParser' :: OrgParser Text -> OrgParser Text
    proseParser' spaceConsumer = do
      a <- MP.choice baseParsers
      b <- MP.many $ MP.choice (spaceParser : baseParsers)
      pure . T.concat $ a : b
      where
        spaceParser = whitespaceCompressor spaceConsumer
        baseParsers =
          [ textParser'
          ]
    textParser' :: OrgParser Text
    textParser' = do
      ws <-
        MP.some
          $ MP.choice
            [ MP.label "normal characters" normalChars
            ]
      pure $ T.concat ws
      where
        normalChars :: OrgParser Text
        normalChars = do
          cs <- MP.some $ normalChar
          pure $ T.pack cs
        normalChar :: OrgParser Char
        normalChar = do
          _ <- MP.notFollowedBy . string $ T.pack ending
          MP.satisfy isNormalChar
        isNormalChar c =
          notElem c disallowedChars
            && (not $ isSpace c)

Unlike f:proseSpaceConsumer, the proseSpaceConsumer' 83 does not check for the correctness of the indentation following a newline (it does not take an expectedIndentLen parameter). This is because we want to be more generous for the case where OID links are found in prose, because sometimes indentation can get silently added when using fill-paragraph in Emacs (if the OID link target gets unlucky and ends up getting hard wrapped by fill-paragraph). Otherwise, this parser will fail, which would then make the OID link become wrongly parsed.

Now we could punish the user for having erroneous indentation, but because org-toggle-link-display can hide this error caused by fill-paragraph (and because the link target contents will be hidden), we take the approach of being a bit lenient here. We still disallow newlines inside the link target inside code blocks, which is why insideCodeBlock is still a parameter for proseSpaceConsumer'. See ti:0006-links-oids-lenient-whitespace.

5.3.3 Tests

Here are some basic links.

Sometimes we have text that look like links initially but turn out to be non-links.

Bracketed links can be preceded by ( which means f:linkParser won't be able to parse it directly; however, in f:textParser (specifically normalChar) we take care to ensure to stop consuming input if we see a double left bracket [[. This way, the input will look like [[... which will then allow f:linkParser to parse it. See ti:0006-links-inside-parentheses for an example.

ti:0006-links-oids has some more complex input.

If we encounter a link in the middle of a paragraph (where the OID portion is broken up incorrectly such that there is additional whitespace at the beginning of a line), we should still be able to parse the \n as a single space character because that's almost always the intent. This situation can happen when invoking fill-paragraph in Emacs with org-toggle-link-display.

So in ti:0006-links-oids-lenient-whitespace, even though the string (if we parse it naively) should look like with\n newline, that \n will get compressed so that we just get with newline instead.

A link to a line label will only have a link display name if the user defines it explicitly. That is, [[(foo)]] won't have a name 85, but [[(foo)][explicit name]] will have one 86.

5.8 Line labels

Line labels are embedded inside a block, and can be linked to from outside the block. Below is an example; we give two code blocks, the first with the line labels disabled and the second with the same content but with line labels enabled, so that you can see the full syntax if you're reading this from HTML:

(save-excursion              ; (ref:a)
   (goto-char (point-min))   ; (ref:foo)
(save-excursion              ; 88
   (goto-char (point-min))   ; 89

The (ref:a) and (ref:foo) will become targets that can be linked to via [[(a)]] and [[(foo)]]. So now you can write 88 and 89 to refer to specific lines in the example above.

5.8.1 Parsing of labels inside code blocks

The line label must start with a (ref: and end with a closing parenthesis ); this is the syntax that Orgmode uses, and which we also adopt in f:lineLabelParser:

f:lineLabelParser
lineLabelParser :: OrgParser LinkTarget
lineLabelParser = do
  _ <- string "(ref:"
  o <- MP.getOffset
  lineLabel <- do
    let disallowedChars = "()[]<>" :: [Char]
        ending = ">>" :: [Char]
    MP.label "line label" $ compressedTextParser disallowedChars ending
  _ <- string ")"
  assertUniqueLineLabel lineLabel o
  pState <- get
  pure
    $ LinkTargetLineLabel
      LinkOid
        { oid = pState.docOid,
          name = "(" <> lineLabel <> ")"
        }

This uses f:compressedTextParser to let the user refer to line labels more freely, without having to be exact about whitespace.

The line labels must be unique inside a document; otherwise you'll run into a parse error, per f:assertUniqueLineLabel. f:assertUniqueLineLabel is virtually the same as f:assertUniqueCellName.

f:assertUniqueLineLabel
assertUniqueLineLabel :: Text -> Int -> OrgParser ()
assertUniqueLineLabel lineLabel offset = do
  pState <- get
  when (member lineLabel pState.lineLabels) $ do
    MP.setOffset offset
    fail
      $ "line label "
      <> show lineLabel
      <> " is not unique"
  let num = pState.lineLabelNumber + 1
  put
    pState
      { lineLabels =
          insert
            lineLabel
            num
            pState.lineLabels,
        lineLabelNumber = num
      }
ti:0006-unique-line-labels
🎯 test/Lilac/ParseSpec/0006-unique-line-labels
#+begin_src sh
a (ref:x)
#+end_src

#+begin_src sh
b (ref:x)
#+end_src
t:0006-unique-line-labels
shouldFailWithMessage
  "0006-unique-line-labels"
  "line label \"x\" is not unique"
  58

The f:lineLabelParser is used in f:bTokenParser, if the block being parsed does not have #+header: :line-label off.

5.8.3 Tangling behavior

By default, line labels are stripped in the tangling process in f:expandBlock. However, sometimes you'd want to tangle these line labels in the source code. One such use case is if you are using a linter for the source code, that doesn't like trailing comment characters.

You can do that with

#+header: :line-label tangle

for the source code block in question. See nix/release.nix for an example.

5.9 Footnotes

Footnotes are essentially named paragraphs. They look like this in practice:

Hello world [fn:foo].

...

* Footnotes

[fn:foo] A paragraph that defines this footnote.

So a footnote is made up of both the link to the footnote from the main text, and a definition of the footnote at the bottom. This requirement of needing to define them somewhere in the document is akin to Line labels.

Both the link and the definition share the same footnote reference, which is parsed with the f:footnoteReferenceParser.

f:footnoteReferenceParser
footnoteReferenceParser :: OrgParser Text
footnoteReferenceParser = do
  _ <- string "[fn:"
  name <- MP.takeWhile1P (Just "footnote name character") (/= ']')
  _ <- string "]"
  pure name

When parsing the link to a footnote, we parse it with the LinkTargetFootnote constructor for data:LinkTarget to distinguish it from LinkTargetOid which are generic links to any other named cell. Then at weaving time, we know how to style it (with a numeric superscript).

f:footnoteLinkParser
footnoteLinkParser :: OrgParser Link
footnoteLinkParser = do
  name <- footnoteReferenceParser
  pState <- get
  displayName <- case lookup name pState.footnoteRefs of
    Nothing -> do
      let newFootnoteNumber = pState.footnoteNumber + 1
          newFootnoteRefs =
            insert
              name
              (newFootnoteNumber, pState.cellNumber)
              pState.footnoteRefs
      modify
        ( \s ->
            s
              { footnoteNumber = newFootnoteNumber,
                footnoteRefs = newFootnoteRefs
              }
        )
      pure $ T.show newFootnoteNumber
    Just (n, _) -> do
      pure $ T.show n
  pure
    $ Link
      { target = LinkTargetFootnote name,
        name = [StyledText {body = displayName, style = styleMaskFrom []}]
      }

As for the footnote definitions, they are expected to be placed on their own, as separate paragraphs. The definition may only be made up of 1 paragraph, because good footnotes are short and to the point.

data:Footnote
type Footnote :: Type
data Footnote = Footnote
  { name :: Text,
    body :: Prose
  }
  deriving stock (Eq, Ord, Generic, Show)
  deriving anyclass (FromJSON, ToJSON)

Parsing footnote definitions is a little different than other cells, because the order in which the footnotes are defined may not necessarily be the order in which the footnotes are referenced from the rest of the document.

For simplicity, Lilac doesn't care about the order in which footnote definitions appear. However, it does expect two conditions:

  1. Footnote definitions must appear together, just once, in the entire document. That is, you may not define footnotes in multiple places 90. For example, see ti:0013-footnotes-multiple-groups.

  2. Inline footnotes (where footnotes are defined in the same area where they are referenced) are not allowed.

During weaving, the footnote definitions are sorted according to the order in which they are referenced (via footnote links). See f:weaveObject for details.

The footnotes are not turned into cells; instead they are used to build up the footnoteDefinitions map 34 in data:OrgParserState, which are then dumped into data:OrgDoc. This is because there is no point in preserving information about the order in which these footnote definitions are defined; in other words, it's useless to store footnote definitions as individual cells in the order they're defined, if during weave time we are going to sort them anyway in a (possibly) different order.

f:footnoteDefinitionParser
footnoteDefinitionParser :: OrgParser Cell
footnoteDefinitionParser = do
  name <- footnoteReferenceParser
  _ <- onlySpaces
  modify (\s -> s {insideFootnoteDefinition = Just name})
  (CParagraph prose) <- paragraphParser
  pState <- get
  o <- MP.getOffset
  when
    ( (not pState.prevCellIsFootnote)
        && (pState.footnoteDefinitions /= M.empty)
    )
    $ do
      MP.setOffset o
      fail "cannot have more than one group of footnote definitions" -- 90
  modify
    ( \s ->
        s
          { footnoteDefinitions =
              insert name prose pState.footnoteDefinitions,
            insideFootnoteDefinition = Nothing
          }
    )
  pure $ CFootnote Footnote {name = name, body = prose}

When we have finished parsing the document, it's expected that all footnote links and definitions line up with each other (that there is an exact 1:1 match). If there are either links that don't have an associated reference or a definition without an associated link, it's considered an error.

f:reconcileFootnotes
reconcileFootnotes :: OrgParser (Map Text (Int, LCI))
reconcileFootnotes = do
  pState <- get
  let referenced = M.keysSet pState.footnoteRefs
      defined = M.keysSet pState.footnoteDefinitions
      brokenLinks = Set.difference referenced defined
      unusedDefs = Set.difference defined referenced
      hasBrokenLinks = length brokenLinks /= 0
      hasUnusedDefs = length unusedDefs /= 0
      brokenLinksMsg = T.unpack . unwords $ toList brokenLinks
      unusedDefsMsg = T.unpack . unwords $ toList unusedDefs
  if
    | hasBrokenLinks -> fail $ "broken footnote links: " <> brokenLinksMsg
    | hasUnusedDefs -> fail $ "unused footnote definitions: " <> unusedDefsMsg
    | otherwise -> pure pState.footnoteRefs

5.9.1 Tests

Check for broken links.

Check for unused definitions.

ti:0013-footnotes-unused-def
🎯 test/Lilac/ParseSpec/0013-footnotes-unused-def
Hello world.

[fn:hello] Foo.
t:0013-footnotes-unused-def
shouldFailWithMessage
  "0013-footnotes-unused-def"
  "unused footnote definitions: hello"
  30

Basic happy path.

ti:0013-footnotes-simple
🎯 test/Lilac/ParseSpec/0013-footnotes-simple
Hello [fn:hello] world.

[fn:hello] Foo.
Expected cells for 0013-footnotes-simple
[ CParagraph
    $ Prose
      [ ptext "Hello" [],
        PTokenLink
          Link
            { target = LinkTargetFootnote "hello",
              name = [stext "1" []]
            },
        ptext " world." []
      ],
  CFootnote
    Footnote
      { name = "hello",
        body = Prose [ptext "Foo." []]
      }
]
t:0013-footnotes-simple
shouldParseWithState
  "0013-footnotes-simple"
  OrgDoc
    { oid = nilOid,
      cells =
        Expected cells for 0013-footnotes-simple,
      pseudoEdges = [],
      footnoteRefs =
        fromList
          [ ("hello", (1, LCI 0))
          ],
      glossary = M.empty,
      lineLabels = M.empty,
      cslPath = Nothing,
      bibPaths = [],
      citations = [],
      meta = fromList [("ID", "00000000-0000-0000-0000-000000000000")]
    }
  defaultOrgParserState
    { footnoteNumber = 1,
      footnoteRefs =
        fromList
          [ ("hello", (1, LCI 0))
          ],
      footnoteDefinitions =
        fromList
          [ ( "hello",
              Prose [ptext "Foo." []]
            )
          ],
      prevCellIsFootnote = True,
      glossary = M.empty,
      lineLabels = M.empty
    }

Complex example.

ti:0013-footnotes-complex
🎯 test/Lilac/ParseSpec/0013-footnotes-complex
Hello [fn:a] world. [fn:b]

[fn:b] Definition of b.

[fn:a] Definition of a.
Expected cells for 0013-footnotes-complex
[ CParagraph
    $ Prose
      [ ptext "Hello" [],
        PTokenLink
          Link
            { target = LinkTargetFootnote "a",
              name = [stext "1" []]
            },
        ptext " world." [],
        PTokenLink
          Link
            { target = LinkTargetFootnote "b",
              name = [stext "2" []]
            }
      ],
  CFootnote
    Footnote
      { name = "b",
        body = Prose [ptext "Definition of b." []]
      },
  CFootnote
    Footnote
      { name = "a",
        body = Prose [ptext "Definition of a." []]
      }
]
t:0013-footnotes-complex
shouldParseWithState
  "0013-footnotes-complex"
  OrgDoc
    { oid = nilOid,
      cells =
        Expected cells for 0013-footnotes-complex,
      pseudoEdges = [],
      footnoteRefs =
        fromList
          [ ("a", (1, LCI 0)),
            ("b", (2, LCI 0))
          ],
      glossary = M.empty,
      lineLabels = M.empty,
      cslPath = Nothing,
      bibPaths = [],
      citations = [],
      meta = fromList [("ID", "00000000-0000-0000-0000-000000000000")]
    }
  defaultOrgParserState
    { footnoteNumber = 2,
      footnoteRefs =
        fromList
          [ ("a", (1, LCI 0)),
            ("b", (2, LCI 0))
          ],
      footnoteDefinitions =
        fromList
          [ ("a", Prose [ptext "Definition of a." []]),
            ("b", Prose [ptext "Definition of b." []])
          ],
      prevCellIsFootnote = True,
      glossary = M.empty,
      lineLabels = M.empty
    }

ti:0013-footnotes-multiple-groups is like ti:0013-footnotes-complex, but we have a non-footnote cell that separates the two footnote definitions. This is not allowed.

ti:0013-footnotes-multiple-groups
🎯 test/Lilac/ParseSpec/0013-footnotes-multiple-groups
Hello [fn:a] world. [fn:b]

[fn:b] Definition of b.

Some other text.

[fn:a] Definition of a.
t:0013-footnotes-multiple-groups
shouldFailWithMessage
  "0013-footnotes-multiple-groups"
  "cannot have more than one group of footnote definitions"
  95

6 Citations

Citations are special kinds of links, but due to their complexity, we discuss them separately here in their own dedicated section.

The CSL specification says "citations consist of one or more cites to individual items". A cite is a single (unique) pointer to a bibliographical work. The description of the bibliographical work itself is defined separately from a cite, and the collection of all bibliographical works cited in a document is called the bibliography.

Citations are similar to footnotes, but there are some major differences:

  1. A special Citation Style Language (CSL) file is needed to be able to weave both the bibliography and citations. These CSL files are just XML files. See this repository for an example.

  2. The equivalent of a "footnote definition" is the global description of the bibliographical work, which can be a book, journal, article, web page, etc. In a sense, these descriptions are a subset of the universe of possible footnote definitions.

    These descriptions are expected to be stored in CSL JSON format, separate from the Org file itself.

  3. For weaving the bibliography, we have to say #+print_bibliography: in the place where we want to have them woven. From the reader's point of view, this is the equivalent of footnote definitions for footnotes.

  4. A single cite may also use a locator, which refers to a more specific section in the work (such as via page numbers). It can also be flanked by affixes (a prefix or suffix), which just means text that can come before or after the cite to describe it more in natural language (such as styled text or even inline math fragments).

    So whereas a footnote link may only need a numerical superscript for presentation when woven, citations need much more work. This translates to additional work required during the parsing stage.

The following sections take a closer look at each of these areas.

6.1 CSL style file (XML)

The CSL file is an XML file specifying various styling properties for rendering both the citations themselves and also the bibliography. This file can be specified as part of the top-level document metadata in f:docMetaParser with the #+cite_export key, whose value is checked by f:validateCslPath.

f:validateCslPath
validateCslPath :: (MonadFail m) => Text -> m (Maybe FilePath)
validateCslPath maybeCslPath
  | T.isPrefixOf cslPrefix maybeCslPath =
      pure $ T.unpack <$> T.stripPrefix cslPrefix maybeCslPath
  | otherwise =
      fail
        $ show cslPrefix
        <> " prefix required for specifying CSL file"
  where
    cslPrefix :: Text
    cslPrefix = "csl "

The value must be prefixed with csl . This is just Org convention that we've carried over into Lilac.

ti:0014-citations-csl-path
🎯 test/Lilac/ParseSpec/0014-citations-csl-path
#+cite_export: csl foo.csl
t:0014-citations-csl-path
shouldParseWithState
  "0014-citations-csl-path"
  OrgDoc
    { oid = nilOid,
      cells = [],
      pseudoEdges = [],
      footnoteRefs = M.empty,
      glossary = M.empty,
      lineLabels = M.empty,
      cslPath = Just "foo.csl",
      bibPaths = [],
      citations = [],
      meta =
        fromList
          [ ("ID", "00000000-0000-0000-0000-000000000000")
          ]
    }
  defaultOrgParserState

6.2 CSL JSON (bibliographical entries)

The CSL JSON defines a list of bibliographical works. An Org document can refer to one of these, and may cite one or more of the works therein.

A document may refer to multiple CSL JSON files. All are consulted during weaving in order to generate bibliographies. The tricky part here is that you can define them more than once. However, they all use the same #+bibliography: key. So we cannot simply combine them into a Map because the similar keys will override each other.

Instead, we can collect all the values that share the same bibliography key with f:collectValsFor. See how this is used in f:docMetaParser.

f:collectValsFor
collectValsFor :: Text -> [(Text, Text)] -> ([Text], [(Text, Text)])
collectValsFor key kvs =
  let otherKVs = filter (\(k, _) -> k /= key) kvs
      vals = reverse $ foldl' f [] kvs
   in (vals, otherKVs)
  where
    f :: [Text] -> (Text, Text) -> [Text]
    f acc (k, v)
      | k == key = v : acc
      | otherwise = acc
ti:0014-citations-csl-json-paths
🎯 test/Lilac/ParseSpec/0014-citations-csl-json-paths
#+bibliography: a.json
#+bibliography: b.json
t:0014-citations-csl-json-paths
shouldParseWithState
  "0014-citations-csl-json-paths"
  OrgDoc
    { oid = nilOid,
      cells = [],
      pseudoEdges = [],
      footnoteRefs = M.empty,
      glossary = M.empty,
      lineLabels = M.empty,
      cslPath = Nothing,
      bibPaths =
        [ "a.json",
          "b.json"
        ],
      citations = [],
      meta =
        fromList
          [ ("ID", "00000000-0000-0000-0000-000000000000")
          ]
    }
  defaultOrgParserState

6.2.1 Default CSL JSON

You can also define a global bibliography file in the ProjectConf (data:ProjectConf7). Doing so will essentially create an automatic #+bibliography: ... line in all Org documents in the archive.

If you choose to do this, then in Emacs you should set the org-cite-global-bibliography variable to the same equivalent path, so that the interactive org-cite-insert command will know where to read bibliographies.

6.3 Printing bibliographies

The document must specify where to print the list of bibliographical references that have been cited (otherwise, if we were to omit this, the citations become somewhat meaningless). This is done by having a line that just says #+print_bibliography: (note the trailing colon :).

Orgmode supports passing in additional options here to the CSL citation export processor, but for simplicity Lilac doesn't support any additional options here.

f:bibliographyLocationParser
bibliographyLocationParser :: OrgParser Cell
bibliographyLocationParser = do
  _ <- string "#+print_bibliography:"
  pure $ CCommand PrintBibliography
ti:0014-citations-bibliography-location
🎯 test/Lilac/ParseSpec/0014-citations-bibliography-location
#+cite_export: csl foo.csl
#+bibliography: a.json
#+print_bibliography:
t:0014-citations-bibliography-location
shouldParseWithState
  "0014-citations-bibliography-location"
  OrgDoc
    { oid = nilOid,
      cells = [CCommand PrintBibliography],
      pseudoEdges = [],
      footnoteRefs = M.empty,
      glossary = M.empty,
      lineLabels = M.empty,
      cslPath = Just "foo.csl",
      bibPaths = ["a.json"],
      citations = [],
      meta =
        fromList
          [ ("ID", "00000000-0000-0000-0000-000000000000")
          ]
    }
  defaultOrgParserState

6.4 Citation data structures

With the boilerplate in the preceding sections out of the way, we can focus on encoding all of the various parts of citations.

A data:Citation is a collection of one or more cites. The idea is that you can have multiple cites in a single span of text.

data:Citation
type Citation :: Type
data Citation = Citation
  { styleVariation :: (Maybe Text, Maybe Text),
    cites :: [Cite],
    noteNumber :: Maybe Int,
    prefix :: Prose,
    suffix :: Prose
  }
  deriving stock (Eq, Ord, Generic, Show)
  deriving anyclass (FromJSON, ToJSON)

If you just want to see what citations look like in practice, skip to the Tests below.

The data:Citation also specifies the style to be used for rendering the key in data:Cite, via the styleVariation. This is just an additional way of tweaking the style to use on top of the overall CSL style in CSL file.

data:Cite
type Cite :: Type
data Cite = Cite
  { id :: Text, -- 91
    locator :: Maybe (Text, Text),
    prefix :: Prose,
    suffix :: Prose
  }
  deriving stock (Eq, Ord, Generic, Show)
  deriving anyclass (FromJSON, ToJSON)

Each data:Cite also has its own prefix and suffix for describing things using regular prose. Contrast this with the prefix and suffix at the data:Citation level, which are for the entire citation.

The most important field for data:Cite is the id 91, which refers to a unique bibliographical work. It's expected that this ID is found in one of the CSL JSON files. Orgmode calls this the key, but we prefer id because there is no immediate hashmap data structure here where we use it as a key.

The f:citationParser parses citations in the text.

f:citationParser
citationParser :: OrgParser Int
citationParser = do
  failIfVerbatimOrCode
  pState <- get
  _ <- string "[cite"
  modify (\s -> s {insideCitation = True})
  sv1 <- MP.optional $ char '/' >> MP.label "style variation 1" svParser
  sv2 <- MP.optional $ char '/' >> MP.label "style variation 2" svParser
  _ <- char ':'
  _ <- MP.optional intraCellWhitespaceParser
  citationPrefix <-
    MP.optional $ do
      _ <- MP.notFollowedBy pseudoCiteParser
      p <- citeAffixParser True
      _ <- MP.optional intraCellWhitespaceParser
      _ <- char ';'
      pure p
  cites <-
    MP.sepEndBy1
      citeParser
      (char ';' >> MP.optional intraCellWhitespaceParser)
  citationSuffix <-
    MP.optional $ do
      _ <- MP.notFollowedBy pseudoCiteParser
      p <- citeAffixParser True
      _ <- MP.optional intraCellWhitespaceParser
      pure p
  _ <- MP.optional intraCellWhitespaceParser
  _ <- char ']'
  let maybeNoteNumber = do
        footnoteName <- pState.insideFootnoteDefinition
        fmap fst $ lookup footnoteName pState.footnoteRefs
      c =
        Citation
          { styleVariation = (T.pack <$> sv1, T.pack <$> sv2),
            noteNumber = maybeNoteNumber,
            cites = cites,
            prefix = fromMaybe (Prose []) citationPrefix,
            suffix = fromMaybe (Prose []) citationSuffix
          }
  modify
    ( \s ->
        s
          { insideCitation = False,
            citations = c : s.citations -- 92
          }
    )
  pure $ length pState.citations -- 93
  where
    svParser :: OrgParser [Char]
    svParser =
      MP.some
        $ MP.satisfy (\c -> isAlphaNumChar c || c == '_' || c == '-')
    pseudoCiteParser = do
      prefix <-
        MP.optional $ do
          p <- citeAffixParser True
          _ <- intraCellWhitespaceParser
          pure p
      citeId <- citeIdParser
      pure (prefix, citeId)

The f:intraCellWhitespaceParser parses spaces and newlines, but does not cross cell boundaries (no double newlines). In other words, a citation may not have a double newline in it.

f:intraCellWhitespaceParser
intraCellWhitespaceParser :: OrgParser [Char]
intraCellWhitespaceParser =
  MP.label "intra-cell whitespace"
    $ MP.some
    $ MP.choice
      [ MP.satisfy (\c -> c == ' '),
        do
          MP.notFollowedBy cellBoundary
          char '\n'
      ]

Anyway, the big picture is that every citation looks like [cite...], and that the cites within the citation are parsed separately with f:citeParser. Also, note that we return just a single integer 93. This is simply the index of the list of citations we build up in 92. This list is finalized in f:collectCitations, which is used by f:orgDocParser.

f:collectCitations
collectCitations :: OrgParser [Citation]
collectCitations = do
  pState <- get
  pure $ reverse pState.citations

We have to build up a single list of all citations because when we call Citeproc.citeproc, we have to provide all citations used in the document; this is the only way to get numbering correct with a style like IEEE. For example, if we were to call Citeproc.citeproc once for every citation on its own, those citations would all get numbered as [1] because Citeproc will only see a single citation at a time.

6.4.1 Parsing a single cite

The f:citeParser parses a single data:Cite. A cite is composed of four pieces:

  1. (Optional) The prefix, which is some explanatory prose just before the id.

  2. The id (Orgmode calls this the key), which refers to a unique bibliographical entry.

  3. (Optional) The locator, which identifies some subsection of the cited bibliographical entry, such as page or chapter numbers.

  4. (Optional) The suffix, which is some explanatory prose that follows the cite id and optional locator.

f:citeParser
citeParser :: OrgParser Cite
citeParser = do
  _ <-
    MP.try
      . MP.lookAhead -- 94
      $ MP.optional citePrefixParser
      >> citeIdParser
  citePrefix <- MP.optional citePrefixParser
  citeId <- citeIdParser
  citeLocator <-
    MP.optional . MP.try $ do
      _ <- intraCellWhitespaceParser
      citeLocatorParser -- 95
  citeSuffix <-
    MP.optional $ do
      _ <- intraCellWhitespaceParser
      citeAffixParser False
  pure
    Cite
      { id = citeId,
        locator = citeLocator,
        prefix = fromMaybe (Prose []) citePrefix,
        suffix = fromMaybe (Prose []) citeSuffix
      }
  where
    citePrefixParser = do
      p <- citeAffixParser True
      _ <- intraCellWhitespaceParser
      pure p

We have to use MP.lookAhead 94 because otherwise there is no way to tell apart a cite prefix (which precedes a cite id like @foo) from a citation prefix.

The Orgmode syntax description says this about the allowed characters for a cite key (what we call an id):

A string made of any word-constituent character, -, ., :, ?, !, `, ', /, *, @,
+, |, (, ), {, }, <, >, &, _, ^, $, #, %, or ~.

And so that's why we allow such a broad range of characters in f:citeIdParser.

f:citeIdParser
citeIdParser :: OrgParser Text
citeIdParser = do
  _ <- char '@'
  citeId <-
    MP.takeWhile1P
      (Just "citeId")
      (\c -> isAlphaNumChar c || c `T.elem` "-.:?!`'/*@+|(){}<>&_^$#%~")
  pure citeId

The f:citeLocatorParser 95 parses the locator, which is made up of two parts. The first part is the term, which describes the location inside the work, such as "chapter" or "page". See f:citeLocatorTermParser for all supported terms. If omitted, this defaults to "page".

The second part is a CSV of coordinates, which can be a number (e.g., 35), a section (e.g., 1.2.3), or a range of numbers (e.g., 35-50) or sections (e.g., 1.2-4). We don't bother with roman numerals. See t:0014-citations-locators for more examples.

f:citeLocatorParser
citeLocatorParser :: OrgParser (Text, Text)
citeLocatorParser =
  MP.choice
    [ explicitLocator,
      implicitLocator
    ]
  where
    explicitLocator :: OrgParser (Text, Text)
    explicitLocator = do
      term <- citeLocatorTermParser
      _ <- intraCellWhitespaceParser
      coords <- citeLocatorCoordsParser
      pure (term, coords)
    implicitLocator :: OrgParser (Text, Text)
    implicitLocator = do
      coords <- citeLocatorCoordsParser
      pure ("page", coords)

The terms in f:citeLocatorTermParser are mostly lifted from org-cite-csl--label-alist from Orgmode. Unlike Orgmode, we do not allow special characters like or §.

f:citeLocatorTermParser
citeLocatorTermParser :: OrgParser Text
citeLocatorTermParser =
  MP.choice $ map (MP.try . locatorTermParser) locatorTerms
  where
    locatorTermParser :: (Text, Text) -> OrgParser Text
    locatorTermParser (humanFriendlyTerm, term) = do
      _ <- string humanFriendlyTerm
      pure term
    locatorTerms :: [(Text, Text)]
    locatorTerms =
      [ ("books", "book"),
        ("book", "book"),
        ("bks.", "book"),
        ("bk.", "book"),
        ("chapters", "chapter"),
        ("chapter", "chapter"),
        ("chaps.", "chapter"),
        ("chap.", "chapter"),
        ("ch.", "chapter"),
        ("columns", "column"),
        ("column", "column"),
        ("cols.", "column"),
        ("col.", "column"),
        ("figures", "figure"),
        ("figure", "figure"),
        ("figs.", "figure"),
        ("fig.", "figure"),
        ("folios", "folio"),
        ("folio", "folio"),
        ("fols.", "folio"),
        ("fol.", "folio"),
        ("numbers", "number"),
        ("number", "number"),
        ("nos.", "number"),
        ("no.", "number"),
        ("lines", "line"),
        ("line", "line"),
        ("ll.", "line"),
        ("l.", "line"),
        ("notes", "note"),
        ("note", "note"),
        ("nn.", "note"),
        ("n.", "note"),
        ("opus", "opus"),
        ("opp.", "opus"),
        ("op.", "opus"),
        ("pages", "page"),
        ("page", "page"),
        ("pp.", "page"),
        ("p.", "page"),
        ("paragraphs", "paragraph"),
        ("paragraph", "paragraph"),
        ("paras.", "paragraph"),
        ("para.", "paragraph"),
        ("parts", "part"),
        ("part", "part"),
        ("pts.", "part"),
        ("pt.", "part"),
        ("sections", "section"),
        ("section", "section"),
        ("secs.", "section"),
        ("sec.", "section"),
        ("sub verbo", "sub verbo"),
        ("s.vv.", "sub verbo"),
        ("s.v.", "sub verbo"),
        ("verses", "verse"),
        ("verse", "verse"),
        ("vv.", "verse"),
        ("v.", "verse"),
        ("volumes", "volume"),
        ("volume", "volume"),
        ("vols.", "volume"),
        ("vol.", "volume")
      ]

The f:citeLocatorCoordsParser parses the coordinates, which is a comma-separated value.

f:citeLocatorCoordsParser
citeLocatorCoordsParser :: OrgParser Text
citeLocatorCoordsParser = do
  coords <-
    MP.sepBy1
      ( MP.choice
          [ MP.try citeLocatorCoordRangeParser,
            citeLocatorCoordParser
          ]
      )
      (char ',' >> intraCellWhitespaceParser)
  pure $ T.intercalate ", " coords

The f:citeLocatorCoordParser can parse a single number or a section (numbers separated by periods). The use of MP.sepBy1 guarantees that we don't get multiple periods in a row.

f:citeLocatorCoordParser
citeLocatorCoordParser :: OrgParser Text
citeLocatorCoordParser = do
  numCoord <- MP.sepBy1 numParser $ char '.'
  pure $ T.intercalate "." numCoord
  where
    numParser :: OrgParser Text
    numParser = MP.takeWhile1P (Just "locatorCoordNum") isDigitChar

The f:citeLocatorCoordRangeParser just parses a range of numbers or sections.

f:citeLocatorCoordRangeParser
citeLocatorCoordRangeParser :: OrgParser Text
citeLocatorCoordRangeParser = do
  n1 <- citeLocatorCoordParser
  _ <- char '-'
  n2 <- citeLocatorCoordParser
  pure $ T.concat [n1, "-", n2]

The f:citeAffixParser can parse anything, except for semicolons and the @ symbol if parsing the prefix (because otherwise this will end up swallowing the cite id such as @foo).

f:citeAffixParser
citeAffixParser :: Bool -> OrgParser Prose
citeAffixParser isPrefix = do
  modify (\s -> s {insideCitePrefix = isPrefix})
  pState <- get
  prose <-
    proseParser
      . whitespaceCompressor
      $ proseSpaceConsumer pState.continuationIndent
  modify (\s -> s {insideCitePrefix = False})
  failIfLeftoverActiveStyles
  failIfUnbalancedBrackets -- 96
  pure $ compressProse prose

The f:failIfUnbalancedBrackets helper 96 fails if it detects if square brackets have not been balanced by the end of the citation.

f:failIfUnbalancedBrackets
failIfUnbalancedBrackets :: OrgParser ()
failIfUnbalancedBrackets = do
  pState <- get
  when (pState.bracketBalance /= 0) $ do
    fail "unbalanced brackets"

6.4.2 Tests

Simple citation, without any locators or affixes.

ti:0014-citations-single-cite
🎯 test/Lilac/ParseSpec/0014-citations-single-cite
#+cite_export: csl foo.csl
#+bibliography: a.json
[cite/v1/v2: @foo123-.:?!`'/*@+|(){}<>&_^$#%~]
t:0014-citations-single-cite
shouldParseWithState
  "0014-citations-single-cite"
  OrgDoc
    { oid = nilOid,
      cells =
        Expected prose for 0014-citations-single-cite,
      pseudoEdges = [],
      footnoteRefs = M.empty,
      glossary = M.empty,
      lineLabels = M.empty,
      cslPath = Just "foo.csl",
      bibPaths = ["a.json"],
      citations =
        [ Citation
            { styleVariation = (Just "v1", Just "v2"),
              noteNumber = Nothing,
              cites =
                [ Cite
                    { id = "foo123-.:?!`'/*@+|(){}<>&_^$#%~",
                      locator = Nothing,
                      prefix = Prose [],
                      suffix = Prose []
                    }
                ],
              prefix = Prose [],
              suffix = Prose []
            }
        ],
      meta =
        fromList
          [ ("ID", "00000000-0000-0000-0000-000000000000")
          ]
    }
  defaultOrgParserState
Expected prose for 0014-citations-single-cite
[ CParagraph
    $ Prose
      [ PTokenCitation 0
      ]
]

Cite with various (typical) locators.

ti:0014-citations-locators
🎯 test/Lilac/ParseSpec/0014-citations-locators
#+cite_export: csl foo.csl
#+bibliography: a.json
[cite: @a 15]
[cite: @b 15-30]
[cite: @c chapters 15-30, 35]
[cite: @d secs. 1.5-10]
[cite: @e sec. 1.22.3]
[cite:@f]
t:0014-citations-locators
shouldParseWithState
  "0014-citations-locators"
  OrgDoc
    { oid = nilOid,
      cells =
        Expected prose for 0014-citations-locators,
      pseudoEdges = [],
      footnoteRefs = M.empty,
      glossary = M.empty,
      lineLabels = M.empty,
      cslPath = Just "foo.csl",
      bibPaths = ["a.json"],
      citations =
        [ Citation
            { styleVariation = (Nothing, Nothing),
              noteNumber = Nothing,
              cites =
                [ Cite
                    { id = "a",
                      locator = Just ("page", "15"),
                      prefix = Prose [],
                      suffix = Prose []
                    }
                ],
              prefix = Prose [],
              suffix = Prose []
            },
          Citation
            { styleVariation = (Nothing, Nothing),
              noteNumber = Nothing,
              cites =
                [ Cite
                    { id = "b",
                      locator = Just ("page", "15-30"),
                      prefix = Prose [],
                      suffix = Prose []
                    }
                ],
              prefix = Prose [],
              suffix = Prose []
            },
          Citation
            { styleVariation = (Nothing, Nothing),
              noteNumber = Nothing,
              cites =
                [ Cite
                    { id = "c",
                      locator = Just ("chapter", "15-30, 35"),
                      prefix = Prose [],
                      suffix = Prose []
                    }
                ],
              prefix = Prose [],
              suffix = Prose []
            },
          Citation
            { styleVariation = (Nothing, Nothing),
              noteNumber = Nothing,
              cites =
                [ Cite
                    { id = "d",
                      locator = Just ("section", "1.5-10"),
                      prefix = Prose [],
                      suffix = Prose []
                    }
                ],
              prefix = Prose [],
              suffix = Prose []
            },
          Citation
            { styleVariation = (Nothing, Nothing),
              noteNumber = Nothing,
              cites =
                [ Cite
                    { id = "e",
                      locator = Just ("section", "1.22.3"),
                      prefix = Prose [],
                      suffix = Prose []
                    }
                ],
              prefix = Prose [],
              suffix = Prose []
            },
          Citation
            { styleVariation = (Nothing, Nothing),
              noteNumber = Nothing,
              cites =
                [ Cite
                    { id = "f",
                      locator = Nothing,
                      prefix = Prose [],
                      suffix = Prose []
                    }
                ],
              prefix = Prose [],
              suffix = Prose []
            }
        ],
      meta =
        fromList
          [ ("ID", "00000000-0000-0000-0000-000000000000")
          ]
    }
  defaultOrgParserState
Expected prose for 0014-citations-locators
[ CParagraph
    $ Prose
      [ PTokenCitation 0,
        ptext " " [],
        PTokenCitation 1,
        ptext " " [],
        PTokenCitation 2,
        ptext " " [],
        PTokenCitation 3,
        ptext " " [],
        PTokenCitation 4,
        ptext " " [],
        PTokenCitation 5
      ]
]

Citations with locators and affixes.

ti:0014-citations-locators-affixes
🎯 test/Lilac/ParseSpec/0014-citations-locators-affixes
#+cite_export: csl foo.csl
#+bibliography: a.json
[cite:prefix @a1 15 suffix]
[cite: @b1 15-30; a really long prefix, maybe? @b2 chap. 4 more suffix]
[cite: @c1 chapters 15-30, 35 suffix with balanced [[brackets] and @sign]]
[cite: @d1 secs. 1.5-10; prefix @d2;@d3]
[cite:@ /see also/ @e1 sec. 1.22.3]
[cite:@f1;*foo* =@fake1= ~@fake2~ @f2 vols. 3-4]
t:0014-citations-locators-affixes
shouldParseWithState
  "0014-citations-locators-affixes"
  OrgDoc
    { oid = nilOid,
      cells =
        Expected prose for 0014-citations-locators-affixes,
      pseudoEdges = [],
      footnoteRefs = M.empty,
      glossary = M.empty,
      lineLabels = M.empty,
      cslPath = Just "foo.csl",
      bibPaths = ["a.json"],
      citations =
        [ Citation
            { styleVariation = (Nothing, Nothing),
              noteNumber = Nothing,
              cites =
                [ Cite
                    { id = "a1",
                      locator = Just ("page", "15"),
                      prefix = Prose [ptext "prefix" []],
                      suffix = Prose [ptext "suffix" []]
                    }
                ],
              prefix = Prose [],
              suffix = Prose []
            },
          Citation
            { styleVariation = (Nothing, Nothing),
              noteNumber = Nothing,
              cites =
                [ Cite
                    { id = "b1",
                      locator = Just ("page", "15-30"),
                      prefix = Prose [],
                      suffix = Prose []
                    },
                  Cite
                    { id = "b2",
                      locator = Just ("chapter", "4"),
                      prefix = Prose [ptext "a really long prefix, maybe?" []],
                      suffix = Prose [ptext "more suffix" []]
                    }
                ],
              prefix = Prose [],
              suffix = Prose []
            },
          Citation
            { styleVariation = (Nothing, Nothing),
              noteNumber = Nothing,
              cites =
                [ Cite
                    { id = "c1",
                      locator = Just ("chapter", "15-30, 35"),
                      prefix = Prose [],
                      suffix =
                        Prose
                          [ ptext "suffix with balanced [[brackets] and @sign]" []
                          ]
                    }
                ],
              prefix = Prose [],
              suffix = Prose []
            },
          Citation
            { styleVariation = (Nothing, Nothing),
              noteNumber = Nothing,
              cites =
                [ Cite
                    { id = "d1",
                      locator = Just ("section", "1.5-10"),
                      prefix = Prose [],
                      suffix = Prose []
                    },
                  Cite
                    { id = "d2",
                      locator = Nothing,
                      prefix = Prose [ptext "prefix" []],
                      suffix = Prose []
                    },
                  Cite
                    { id = "d3",
                      locator = Nothing,
                      prefix = Prose [],
                      suffix = Prose []
                    }
                ],
              prefix = Prose [],
              suffix = Prose []
            },
          Citation
            { styleVariation = (Nothing, Nothing),
              noteNumber = Nothing,
              cites =
                [ Cite
                    { id = "e1",
                      locator = Just ("section", "1.22.3"),
                      prefix =
                        Prose
                          [ ptext "@ " [],
                            ptext "see also" [Italic]
                          ],
                      suffix = Prose []
                    }
                ],
              prefix = Prose [],
              suffix = Prose []
            },
          Citation
            { styleVariation = (Nothing, Nothing),
              noteNumber = Nothing,
              cites =
                [ Cite
                    { id = "f1",
                      locator = Nothing,
                      prefix = Prose [],
                      suffix = Prose []
                    },
                  Cite
                    { id = "f2",
                      locator = Just ("volume", "3-4"),
                      prefix =
                        Prose
                          [ ptext "foo" [Bold],
                            ptext " " [],
                            ptext "@fake1" [Verbatim],
                            ptext " " [],
                            ptext "@fake2" [Code]
                          ],
                      suffix = Prose []
                    }
                ],
              prefix = Prose [],
              suffix = Prose []
            }
        ],
      meta =
        fromList
          [ ("ID", "00000000-0000-0000-0000-000000000000")
          ]
    }
  defaultOrgParserState
Expected prose for 0014-citations-locators-affixes
[ CParagraph
    $ Prose
      [ PTokenCitation 0,
        ptext " " [],
        PTokenCitation 1,
        ptext " " [],
        PTokenCitation 2,
        ptext " " [],
        PTokenCitation 3,
        ptext " " [],
        PTokenCitation 4,
        ptext " " [],
        PTokenCitation 5
      ]
]

Check for citation-level (aka common or global) affixes, with some examples taken from the Org community. These citation-level affixes are useful because it may be the case that the CSL processor decides to reorder the cites.

ti:0014-citations-worg
🎯 test/Lilac/ParseSpec/0014-citations-worg
#+cite_export: csl foo.csl
#+bibliography: a.json
[cite:*global* prefix;@a1;global =suffix=]
[cite/t: see;@source1;@source2;by Smith /et al./]
[cite/t:see;@foo p. 7;@bar pp. 4;by foo]
[cite/a/f:c.f.;the very important @@atkey @ once;the crucial @baz vol. 3]
t:0014-citations-worg
shouldParseWithState
  "0014-citations-worg"
  OrgDoc
    { oid = nilOid,
      cells =
        Expected prose for 0014-citations-worg,
      pseudoEdges = [],
      footnoteRefs = M.empty,
      glossary = M.empty,
      lineLabels = M.empty,
      cslPath = Just "foo.csl",
      bibPaths = ["a.json"],
      citations =
        [ Citation
            { styleVariation = (Nothing, Nothing),
              noteNumber = Nothing,
              cites =
                [ Cite
                    { id = "a1",
                      locator = Nothing,
                      prefix = Prose [],
                      suffix = Prose []
                    }
                ],
              prefix =
                Prose
                  [ ptext "global" [Bold],
                    ptext " prefix" []
                  ],
              suffix =
                Prose
                  [ ptext "global " [],
                    ptext "suffix" [Verbatim]
                  ]
            },
          Citation
            { styleVariation = (Just "t", Nothing),
              noteNumber = Nothing,
              cites =
                [ Cite
                    { id = "source1",
                      locator = Nothing,
                      prefix = Prose [],
                      suffix = Prose []
                    },
                  Cite
                    { id = "source2",
                      locator = Nothing,
                      prefix = Prose [],
                      suffix = Prose []
                    }
                ],
              prefix = Prose [ptext "see" []],
              suffix =
                Prose
                  [ ptext "by Smith " [],
                    ptext "et al." [Italic]
                  ]
            },
          Citation
            { styleVariation = (Just "t", Nothing),
              noteNumber = Nothing,
              cites =
                [ Cite
                    { id = "foo",
                      locator = Just ("page", "7"),
                      prefix = Prose [],
                      suffix = Prose []
                    },
                  Cite
                    { id = "bar",
                      locator = Just ("page", "4"),
                      prefix = Prose [],
                      suffix = Prose []
                    }
                ],
              prefix = Prose [ptext "see" []],
              suffix = Prose [ptext "by foo" []]
            },
          Citation
            { styleVariation = (Just "a", Just "f"),
              noteNumber = Nothing,
              cites =
                [ Cite
                    { id = "@atkey",
                      locator = Nothing,
                      prefix = Prose [ptext "the very important" []],
                      suffix = Prose [ptext "@ once" []]
                    },
                  Cite
                    { id = "baz",
                      locator = Just ("volume", "3"),
                      prefix = Prose [ptext "the crucial" []],
                      suffix = Prose []
                    }
                ],
              prefix = Prose [ptext "c.f." []],
              suffix = Prose []
            }
        ],
      meta =
        fromList
          [ ("ID", "00000000-0000-0000-0000-000000000000")
          ]
    }
  defaultOrgParserState
Expected prose for 0014-citations-worg
[ CParagraph
    $ Prose
      [ PTokenCitation 0,
        ptext " " [],
        PTokenCitation 1,
        ptext " " [],
        PTokenCitation 2,
        ptext " " [],
        PTokenCitation 3
      ]
]

Unbalanced brackets are rejected from the cite prefix.

ti:0014-citations-unbalanced-brackets-prefix-opening
🎯 test/Lilac/ParseSpec/0014-citations-unbalanced-brackets-prefix-opening
#+cite_export: csl foo.csl
#+bibliography: a.json
[cite: [ @foo]
t:0014-citations-unbalanced-brackets-prefix-opening
shouldFailWithMessage
  "0014-citations-unbalanced-brackets-prefix-opening"
  "unbalanced brackets"
  58
ti:0014-citations-unbalanced-brackets-prefix-closing
🎯 test/Lilac/ParseSpec/0014-citations-unbalanced-brackets-prefix-closing
#+cite_export: csl foo.csl
#+bibliography: a.json
[cite: ] @foo]
t:0014-citations-unbalanced-brackets-prefix-closing
shouldFailWithError
  "0014-citations-unbalanced-brackets-prefix-closing"
  ( utoks "]"
      <> etoks "[["
      <> etoks "[fn:"
      <> etoks "\\("
      <> etoks "@"
      <> etoks "\\"
      <> elabel "(raw link) protocol"
      <> etoks "\n"
      <> elabel "normal characters"
  )
  57

Similarly, unbalanced brackets should fail for the cite suffix.

ti:0014-citations-unbalanced-brackets-suffix-opening
🎯 test/Lilac/ParseSpec/0014-citations-unbalanced-brackets-suffix-opening
#+cite_export: csl foo.csl
#+bibliography: a.json
[cite: @foo []
t:0014-citations-unbalanced-brackets-suffix-opening
shouldFail "0014-citations-unbalanced-brackets-suffix-opening"
ti:0014-citations-unbalanced-brackets-suffix-closing
🎯 test/Lilac/ParseSpec/0014-citations-unbalanced-brackets-suffix-closing
#+cite_export: csl foo.csl
#+bibliography: a.json
[cite: @foo ]]
t:0014-citations-unbalanced-brackets-suffix-closing
shouldFailWithError
  "0014-citations-unbalanced-brackets-suffix-closing"
  ( utoks "]]\n"
      <> etoks "[["
      <> etoks "[fn:"
      <> etoks "\\("
      <> etoks "\\"
      <> elabel "(raw link) protocol"
      <> etoks "\n"
      <> elabel "normal characters"
  )
  62

Semicolons must be meaningful; extraneous semicolons are not allowed.

ti:0014-citations-extra-semicolons-basic
🎯 test/Lilac/ParseSpec/0014-citations-extra-semicolons-basic
#+cite_export: csl foo.csl
#+bibliography: a.json
[cite:;@foo]
t:0014-citations-extra-semicolons-basic
shouldFailWithError
  "0014-citations-extra-semicolons-basic"
  ( utoks ";"
      <> etoks "@"
      <> elabel "intra-cell whitespace"
  )
  56
ti:0014-citations-extra-semicolons-consecutive
🎯 test/Lilac/ParseSpec/0014-citations-extra-semicolons-consecutive
#+cite_export: csl foo.csl
#+bibliography: a.json
[orgmode-citation-breakage-workaround@foo;;]

Aside: sadly, using [cite:... directly above seems to break Orgmode's parsing, such that opening this file in Emacs results in an error. So "hide" it from Orgmode with orgmode-citation-breakage-workaround.

orgmode-citation-breakage-workaround
cite:
t:0014-citations-extra-semicolons-consecutive
shouldFailWithError
  "0014-citations-extra-semicolons-consecutive"
  ( utoks ";"
      <> etoks "@"
      <> etoks "]"
      <> elabel "intra-cell whitespace"
  )
  61

Citations cannot be nested. Because we don't parse for nested citations in f:proseParser, in t:0014-citations-nested we end up parsing the magic string [cite: as prose text. So then this triggers the balanced brackets checker.

ti:0014-citations-nested
🎯 test/Lilac/ParseSpec/0014-citations-nested
#+cite_export: csl foo.csl
#+bibliography: a.json
[cite:@foo;[cite: @bar]]
t:0014-citations-nested
shouldFailWithMessage
  "0014-citations-nested"
  "unbalanced brackets"
  67

Citations inside verbatim or code style markers are ignored.

ti:0014-citations-forbidden-in-verbatim-code
🎯 test/Lilac/ParseSpec/0014-citations-forbidden-in-verbatim-code
~[cite:@foo]~
=[cite:@bar]=
t:0014-citations-forbidden-in-verbatim-code
shouldParseWithState
  "0014-citations-forbidden-in-verbatim-code"
  OrgDoc
    { oid = nilOid,
      cells =
        Expected prose for 0014-citations-forbidden-in-verbatim-code,
      pseudoEdges = [],
      footnoteRefs = M.empty,
      glossary = M.empty,
      lineLabels = M.empty,
      cslPath = Nothing,
      bibPaths = [],
      citations = [],
      meta =
        fromList
          [ ("ID", "00000000-0000-0000-0000-000000000000")
          ]
    }
  defaultOrgParserState
Expected prose for 0014-citations-forbidden-in-verbatim-code
[ CParagraph
    $ Prose
      [ ptext "[cite:@foo]" [Code],
        ptext " " [],
        ptext "[cite:@bar]" [Verbatim]
      ]
]

For simplicity, citations are not allowed in headings. This is controlled by the insideHeading boolean in f:headingParser, and respected by f:proseParser.

ti:0014-citations-forbidden-in-headings
🎯 test/Lilac/ParseSpec/0014-citations-forbidden-in-headings
* heading [cite:@foo]
t:0014-citations-forbidden-in-headings
shouldParseWithState
  "0014-citations-forbidden-in-headings"
  OrgDoc
    { oid = nilOid,
      cells =
        [ CHeading
            Heading
              { level = 1,
                section = [1],
                body = Prose [ptext "heading [cite:@foo]" []],
                meta = fromList []
              }
        ],
      pseudoEdges = [],
      footnoteRefs = M.empty,
      glossary = M.empty,
      lineLabels = M.empty,
      cslPath = Nothing,
      bibPaths = [],
      citations = [],
      meta =
        fromList
          [ ("ID", "00000000-0000-0000-0000-000000000000")
          ]
    }
  defaultOrgParserState
    { headingOidStack = [Nothing],
      headingLevel = 1,
      headingSectionNumber = [1]
    }

7 Prose

Prose is a series of contiguous prose text, which can have some styling (bold, italic, etc) as well as links, citations, footnote links, etc, or tokens. So it's just a list of these prose tokens (data:PToken).

newtype:Prose
type Prose :: Type
newtype Prose = Prose
  { unProse :: [PToken]
  }
  deriving stock (Eq, Generic, Ord, Show)
  deriving anyclass (FromJSON, ToJSON)

A PToken can be a styled string, link, citation, or math fragment. For the citation, we only store the index of it (starting from 0), to act as a "pointer" to the list of all citations in the entire document, collected in f:collectCitations.

data:PToken
type PToken :: Type
data PToken
  = PTokenStyledText StyledText
  | PTokenLink Link
  | PTokenCitation Int
  | PTokenMathFragment MathFragment
  | PTokenDivStart Text
  | PTokenDivEnd
  deriving stock (Eq, Generic, Ord, Show)
  deriving anyclass (FromJSON, ToJSON)

The PTokenDivStart and PTokenDivEnd markers are similar to the data:ListMarker, in that they encode start and end points (in HTML). Specifically, they provide equivalent functionality of the CslDiv constructor for the CslJson type in Citeproc. This is needed in order to render citations (e.g., see Expected bibliography for 0400-citeproc-basic). Arguably the CslDiv is more elegant because it encodes a recursive list structure (by way of CslConcat and CslEmpty), and also because it only needs a single constructor CslDiv whereas we require two (PTokenDivStart and PTokenDivEnd). However this is the price we have to pay for not being recursive. It also makes parsing these things difficult, so we avoid parsing them altogether from raw text (notice how f:proseParser does not use them).

The f:proseParser can parse a series of data:PToken values. It is used by both f:headingParser and f:paragraphParser, because they both allow prose text in them. In particular, it avoids parsing Citations inside headings and also if we're already inside another citation (no nesting allowed).

f:proseParser
proseParser :: OrgParser Text -> OrgParser Prose
proseParser spaceConsumer = do
  pState <- get
  let avoidCitations =
        pState.insideHeading || pState.insideCitation
  a <- MP.choice $ baseParsers avoidCitations -- 97
  b <-
    MP.many
      $ MP.choice (spacePTokenParser : baseParsers avoidCitations) -- 98
  pure . Prose . filter nonEmpty $ a : b
  where
    spacePTokenParser = do
      s <- spaceConsumer
      st <- get
      pure
        $ PTokenStyledText
          StyledText {body = s, style = st.styleMask}
    baseParsers avoidCitations
      | avoidCitations =
          [ PTokenLink <$> linkParser,
            PTokenMathFragment <$> mathFragmentInlineParser,
            PTokenStyledText <$> styleMarkerParser,
            PTokenStyledText <$> styledTextParser
          ]
      | otherwise =
          [ PTokenCitation <$> citationParser,
            PTokenLink <$> linkParser,
            PTokenMathFragment <$> mathFragmentInlineParser,
            PTokenStyledText <$> styleMarkerParser,
            PTokenStyledText <$> styledTextParser
          ]
    nonEmpty :: PToken -> Bool
    nonEmpty = \case
      PTokenStyledText styledText -> not $ T.null styledText.body
      PTokenLink {}
      PTokenMathFragment {}
      PTokenCitation {}
      PTokenDivStart {}
      PTokenDivEnd {} -> True

We first parse among the non-whitespace parsers in baseParsers 97, then parse additional tokens that may include whitespace 98. This way, we force the prose to initially start out without any indentation (because the preceding cell, if any, would have swallowed up trailing whitespace automatically via lexeme in f:cellParser); otherwise, f:detectInvalidIndentation would not get a chance to detect this (invalid) indentation.

It may appear that we could alternatively skip past spaces inside prose text by making a lexeme-like wrapper which skips newlines and spaces for each parser in baseParsers. However, that approach has a problem. Consider the case of parsing something like

=foo=...

where the ... will get parsed separately from the foo. We'll end up with two PTokenStyledText objects, one for foo and another for .... This is a problem because we'll also parse

=foo= ...

exactly the same way as two PTokenStyledText objects. This is because the space between the foo and ... will get eaten up by the wrapper and discarded. And now we're in the position where we have to, on export, guess (due to information loss) if we need to insert whitespace between the foo and ....

Instead, explicitly parsing the whitespace with f:whitespaceCompressor lets us parse the two cases above differently, because we save all whitespace we parsed as their own PTokenStyledText objects. We call it a "compressor" because no matter how many spaces the spaceConsumer 99 consumes, we construct only 0 or 1 space characters in the data:StyledText object.

f:whitespaceCompressor
whitespaceCompressor :: OrgParser Text -> OrgParser Text
whitespaceCompressor spaceConsumer = do
  _ <- spaceConsumer -- 99
  MP.lookAhead
    $ MP.choice
      [ MP.eof >> pure "",
        footnoteReferenceParser >> pure "", -- 100
        MP.anySingle >> pure " "
      ]

This parser only succeeds if the given spaceConsumer succeeds. And then it decides to report 0 or 1 space characters depending on what follows (MP.lookAhead). Note that we also do a second level of "compression" in f:compressProse, where we will drop StyledText objects with an empty string as the body.

Whitespace compression also happens when we detect footnote references 100. Specifically, if there is a prose token, some whitespace, and then a footnote reference, we ignore the whitespace. For example, if we have

foo [[fn:bar]]

then the space between foo and [[fn:bar]] is ignored. This way, when we weave this we can get the footnote number superscript "attached" to the foo instead of having it float around by itself which will look odd.

7.1 Compatibility with CiteprocOutput

Citeproc will read the CSL style file (XML) to understand how to make formatting transformations (adding emphasis, changing cases, etc) to citations based on the specific style. The CiteprocOutput typeclass instance describes all of these transformations:

CiteprocOutput typeclass
-- | CSL styles require certain formatting transformations to
-- be defined.  These are defined in the 'CiteprocOutput' class.
-- The library may be used with any structured format that defines
-- these operations.  See the 'Citeproc.CslJson' module for an instance
-- that corresponds to the markup allowed in CSL JSON. See
-- the 'Citeproc.Pandoc' module for an instance for Pandoc 'Inlines'.
class (Semigroup a, Monoid a, Show a, Eq a, Ord a) => CiteprocOutput a where
  toText                      :: a -> Text
  fromText                    :: Text -> a
  dropTextWhile               :: (Char -> Bool) -> a -> a
  dropTextWhileEnd            :: (Char -> Bool) -> a -> a
  addFontVariant              :: FontVariant -> a -> a
  addFontStyle                :: FontStyle -> a -> a
  addFontWeight               :: FontWeight -> a -> a
  addTextDecoration           :: TextDecoration -> a -> a
  addVerticalAlign            :: VerticalAlign -> a -> a
  addTextCase                 :: Maybe Lang -> TextCase -> a -> a
  addDisplay                  :: DisplayStyle -> a -> a
  addQuotes                   :: a -> a
  movePunctuationInsideQuotes :: a -> a
  inNote                      :: a -> a
  mapText                     :: (Text -> Text) -> a -> a
  addHyperlink                :: Text -> a -> a
  localizeQuotes              :: Locale -> a -> a

Any type that has an instance of the CiteprocOutput typeclass will be able to be transformed by Citeproc according to the CSL style file. For us, this type is newtype:Prose, and if we add support for the CiteprocOutput typeclass for it, we'll be able to format both the data:Citation values as well as the CSL JSON (bibliographical entries) with newtype:Prose (so that users could use the familiar syntax for newtype:Prose in the CSL JSON directly, for example).

Unfortunately, adding in full support for all of the above functions in CiteprocOutput typeclass is quite tedious and actually not trivial. For example you have to know how to move around punctuation (periods and commas) from outside of quotes (".) to inside (."). The CslJson type is recursive and encodes quotes as a separate constructor, making these transformations easier.

Because of the difficulty, we don't support a full, proper implementation of CiteprocOutput for newtype:Prose; we take shortcuts and avoid doing a proper implementation in some places (e.g., instance:CiteprocOutput Prose:localizeQuotes). So instead of using newtype:Prose, we use the CslJson type (see f:proseToCslJsonText) and convert this back into newtype:Prose (see f:cslJsonTextToProse).

Now you may be thinking, why bother keeping the (unfinished) implementation of CiteprocOutput for newtype:Prose? It turns out that some of the functions are actually quite useful, in particular instance:CiteprocOutput Prose:fromText for implementing f:cslJsonTextToProse. And also, we may decide to avoid this conversion to CslJson in the future; in that scenario, having around this unfinished implementation would provide a solid starting point.

7.1.1 CiteprocOutput typeclass implementation for newtype:Prose

CiteprocOutput requires the type variable to also be an instance of Semigroup and Monoid. Because a newtype:Prose is just a wrapper around a list of data:PToken values, we don't have to actually define anything ourselves here (as the operations have already been defined for the list ([]) type)2.

instance:Semigroup Prose
instance Semigroup Prose where
  x <> y = Prose $ x.unProse <> y.unProse
instance:Monoid Prose
instance Monoid Prose where
  mempty = Prose []

We define the CiteprocOutput instance for newtype:Prose in instance:CiteprocOutput Prose below.

The functions toText and fromText are for converting the value (in this case newtype:Prose) to and from a textual representation. For us this means, converting text to newtype:Prose (parsing) and back to text again. Let's look at the parsing first.

instance:CiteprocOutput Prose:fromText
fromText input = case MP.runParser
  ( runStateT
      customProseParser -- 101
      defaultOrgParserState
  )
  ""
  input of
  Left err -> error . T.pack $ MP.errorBundlePretty err
  Right (prose, _) -> compressProse prose
  where
    customProseParser = do
      leadingWhitespace <- MP.optional spaceConsumer
      rest <- proseParser spaceConsumer
      case leadingWhitespace of
        Nothing -> pure rest
        Just s ->
          let w =
                PTokenStyledText
                  StyledText
                    { body = s,
                      style = styleMaskFrom []
                    }
           in pure . Prose $ w : rest.unProse
    spaceConsumer :: OrgParser Text
    spaceConsumer = T.pack <$> intraCellWhitespaceParser

This definition is mostly a wrapper around f:proseParser. The tricky part is the leading optional whitespace. f:proseParser by default does not parse leading whitespace, because in regular Org documents we expect indentation to have semantic meaning for list items. However for citations, we don't expect to have to deal with list items at all, so we can still parse indentation. The only caveat is that we have to do it manually by creating a custom parser. That's what we do with the customProseParser 101 helper above.

The definition of toText is a bit more involved, though perhaps mechanical. The key is to convert the data:PToken values back into the Orgmode syntax. That way we can guarantee no loss of information going from Text to Prose and back again to Text.

instance:CiteprocOutput Prose:toText
toText prose = foldl' f T.empty prose.unProse
  where
    f :: Text -> PToken -> Text
    f acc = \case
      PTokenCitation {}
      PTokenDivStart {}
      PTokenDivEnd {} -> acc
      PTokenStyledText styledText
        | T.null styledText.body -> acc
        | otherwise -> acc <> unparseStyledText styledText
      PTokenLink link -> acc <> unparseLink link
      PTokenMathFragment mathFragment -> acc <> unparseMathFragment mathFragment

We don't support converting data:Citation back into Text, because that would suggest a nested citation (where a citation has another citation inside it), which is strictly forbidden, as seen in t:0014-citations-nested.

We also do not support converting PTokenDivStart and PTokenDivEnd to Text, because we never expect them to be written by humans; they only serve to convert CslJson in Citeproc back into newtype:Prose. And also, even if we supported it here, if we wanted to round trip back from Text into newtype:Prose we would fail because f:proseParser does not support parsing these markers.

Converting a data:StyledText back into a Text is straightforward. We simply surround the bold text with the appropriate style markers.

f:unparseStyledText
unparseStyledText :: StyledText -> Text
unparseStyledText styledText =
  getStyleMarkers styledText.style
    <> escapeStyleMarkers styledText.body
    <> getStyleMarkers styledText.style
  where
    getStyleMarkers styleMask =
      foldl' accumulateStyle T.empty . sort $ maskToStyles styleMask
    accumulateStyle :: Text -> Style -> Text
    accumulateStyle acc = \case
      Bold -> acc <> "*"
      Italic -> acc <> "/"
      Underline -> acc <> "_"
      Verbatim -> acc <> "="
      Code -> acc <> "~"
      Subscript
      Superscript
      Smallcaps -> acc
    escapeStyleMarkers = T.foldl' escape T.empty
    escape acc c
      | mustEscape && c `elem` ("*/_=~" :: String) = acc <> T.pack ['\\', c]
      | otherwise = acc <> T.singleton c
    mustEscape =
      not (isCode styledText.style || isVerbatim styledText.style)

Converting a data:Link to Text is straightforward. We try to avoid duplicating information (e.g., if the display name is the same as the link itself, we avoid spelling it out).

For data:MathFragment, we have to disambiguate the three different kinds of declarations of math fragments --- numbered standalone (f:mathFragmentNumberedParser), unnumbered standalone (f:mathFragmentUnnumberedParser), and inline math (f:mathFragmentInlineParser). In terms of conversion to and out of data:PToken values, however, we will only ever use inline math, because the other ones require the math to be in its own cell as a CMathFragment at the data:Cell level, outside of a newtype:Prose. Still, we check for all three types of math fragments for completeness.

f:unparseMathFragment
unparseMathFragment :: MathFragment -> Text
unparseMathFragment mathFragment = mathStart <> mathFragment.body <> mathEnd
  where
    (mathStart, mathEnd)
      | Just _ <- lookup "__equation_number" mathFragment.meta =
          ("\\begin{equation}", "\\end{equation}")
      | Just _ <- lookup "__equation_anon_number" mathFragment.meta =
          ("\\[", "\\]")
      | otherwise = ("\\(", "\\)")

Now we come to dropTextWhile and dropTextWhileEnd functions for the typeclass. These functions are for deleting Text, and correspond to T.dropWhile and T.dropWhileEnd, respectively.

Let's consider an example of how these functions might be implemented. If you look at the definitions used for the CslJson type in Citeproc, you can see that they assume the encoded value is just some Text values (inside the CslText constructor). This makes sense for the CslJson type because it only encodes a Text value, surrounded by any number of other constructors that encode things like emphasis (e.g., CslBold).

Our newtype:Prose is a bit different because it has more semantic information encoded in it beyond just raw Text (see data:PToken). The only really equivalent value is the PTokenStyledText constructor which only deals with Text with some styling on top. The other things like PTokenLink and PTokenMathFragment are not really Text values (we ignore PTokenCitation because citations cannot be nested). So running T.dropWhile won't make much sense if we're looking at a PTokenMathFragment.

Because of the above issue, we compromise by skipping over any non-PTokenStyledText value.

f:dropTextFromStyledText
dropTextFromStyledText :: Bool -> (Char -> Bool) -> Prose -> Prose
dropTextFromStyledText dropFromStart f prose =
  Prose
    . (if dropFromStart then reverse else id)
    . fst
    . foldl' g ([], False)
    $ if dropFromStart then prose.unProse else reverse prose.unProse
  where
    g :: ([PToken], Bool) -> PToken -> ([PToken], Bool)
    g (acc, pastFirst) pToken
      | pastFirst = (pToken : acc, pastFirst)
      | otherwise = case pToken of
          PTokenStyledText styledText
            | T.null styledText.body -> (pToken : acc, pastFirst)
            | otherwise ->
                let modifiedBody = dropper f styledText.body
                    modified =
                      PTokenStyledText
                        StyledText
                          { body = modifiedBody,
                            style = styledText.style
                          }
                 in if T.null modifiedBody
                      then (acc, True)
                      else (modified : acc, True)
          PTokenLink {}
          PTokenMathFragment {}
          PTokenCitation {}
          PTokenDivStart {}
          PTokenDivEnd {} -> (pToken : acc, pastFirst)
    dropper = if dropFromStart then T.dropWhile else T.dropWhileEnd

Adding font variations is not supported, because we have no notion of small caps as a stylistic option.

instance:CiteprocOutput Prose:addFontVariant
addFontVariant fontVariant prose = case fontVariant of
  NormalVariant -> prose
  SmallCapsVariant -> prose

For font styles, we treat ObliqueFont the same as ItalicFont. For NormalFont though, we simply strip all styling.

instance:CiteprocOutput Prose:addFontStyle
addFontStyle fontStyle prose = case fontStyle of
  NormalFont -> Prose $ map noStyle prose.unProse
  ItalicFont -> Prose $ map italicize prose.unProse
  ObliqueFont -> Prose $ map italicize prose.unProse
  where
    noStyle pToken = case pToken of
      PTokenStyledText styledText ->
        PTokenStyledText
          StyledText
            { body = styledText.body,
              style = styleMaskFrom []
            }
      PTokenLink {}
      PTokenCitation {}
      PTokenMathFragment {}
      PTokenDivStart {}
      PTokenDivEnd {} -> pToken
    italicize pToken = case pToken of
      PTokenStyledText styledText ->
        PTokenStyledText
          StyledText
            { body = styledText.body,
              style = styleMaskFrom $ Italic : maskToStyles styledText.style
            }
      PTokenLink {}
      PTokenCitation {}
      PTokenMathFragment {}
      PTokenDivStart {}
      PTokenDivEnd {} -> pToken

For the font weight, we only have one weight, which is Bold.

instance:CiteprocOutput Prose:addFontWeight
addFontWeight fontWeight prose = case fontWeight of
  NormalWeight -> Prose $ map unBold prose.unProse
  BoldWeight -> Prose $ map bold prose.unProse
  LightWeight -> Prose $ map unBold prose.unProse
  where
    bold pToken = case pToken of
      PTokenStyledText styledText ->
        PTokenStyledText
          StyledText
            { body = styledText.body,
              style = styleMaskFrom $ Bold : maskToStyles styledText.style
            }
      PTokenLink {}
      PTokenCitation {}
      PTokenMathFragment {}
      PTokenDivStart {}
      PTokenDivEnd {} -> pToken
    unBold pToken = case pToken of
      PTokenStyledText styledText ->
        PTokenStyledText
          StyledText
            { body = styledText.body,
              style =
                styleMaskFrom
                  . filter (/= Bold)
                  $ maskToStyles styledText.style
            }
      PTokenLink {}
      PTokenCitation {}
      PTokenMathFragment {}
      PTokenDivStart {}
      PTokenDivEnd {} -> pToken

The addTextDecoration definition is straightforward enough, because there is no information loss (it's either having an underline or not having one).

instance:CiteprocOutput Prose:addTextDecoration
addTextDecoration textDecoration prose = case textDecoration of
  NoDecoration -> Prose $ map noUnderline prose.unProse
  UnderlineDecoration -> Prose $ map underline prose.unProse
  where
    underline pToken = case pToken of
      PTokenStyledText styledText ->
        PTokenStyledText
          StyledText
            { body = styledText.body,
              style = styleMaskFrom $ Underline : maskToStyles styledText.style
            }
      PTokenLink {}
      PTokenCitation {}
      PTokenMathFragment {}
      PTokenDivStart {}
      PTokenDivEnd {} -> pToken
    noUnderline pToken = case pToken of
      PTokenStyledText styledText ->
        PTokenStyledText
          StyledText
            { body = styledText.body,
              style =
                styleMaskFrom
                  . filter (/= Underline)
                  $ maskToStyles styledText.style
            }
      PTokenLink {}
      PTokenCitation {}
      PTokenMathFragment {}
      PTokenDivStart {}
      PTokenDivEnd {} -> pToken

We don't support adding vertical alignment, so we don't do anything here.

instance:CiteprocOutput Prose:addVerticalAlign
addVerticalAlign verticalAlign prose = case verticalAlign of
  BaselineAlign
  SupAlign
  SubAlign -> prose

We don't support case transformations (converting to uppercase, lowercase, etc). The Citeproc library has a Citeproc.CaseTransform module which provides much of the work for this. However, we don't bother with this for now to avoid additional complexity.

instance:CiteprocOutput Prose:addTextCase
addTextCase _ textCase prose = case textCase of
  Lowercase
  Uppercase
  CapitalizeFirst
  CapitalizeAll
  SentenceCase
  TitleCase
  PreserveCase -> prose

We don't support display transformations (whether to display as a block, indented, etc). This is presumably for the bibliography list only.

instance:CiteprocOutput Prose:addDisplay
addDisplay displayStyle prose = case displayStyle of
  DisplayBlock
  DisplayLeftMargin
  DisplayRightInline
  DisplayIndent -> prose

Adding quotes means just surrounding the prose with quote characters.

instance:CiteprocOutput Prose:addQuotes
addQuotes prose = Prose $ [quote] <> prose.unProse <> [quote]
  where
    quote =
      PTokenStyledText
        StyledText
          { body = "\"",
            style = styleMaskFrom []
          }

The movePunctuationInsideQuotes function moves all commas and periods to come just before the quote, instead of after it. For example, "a". would be converted into "b.". Because of the way styles are handled though, a data:PToken can only see text that have the same style; if there are two of them, and they each have a piece of the punctuation combination we are concerned about, this won't work. However, such cases should be rare, and even if we did support that case, it wouldn't make sense to move a bold period inside of a non-bold quote character, for example. So working inside of a single data:PToken should be reasonable enough.

instance:CiteprocOutput Prose:movePunctuationInsideQuotes
movePunctuationInsideQuotes prose =
  Prose $ map f prose.unProse
  where
    f pToken = case pToken of
      PTokenStyledText styledText ->
        PTokenStyledText
          StyledText
            { body = g styledText.body,
              style = styledText.style
            }
      PTokenLink {}
      PTokenCitation {}
      PTokenMathFragment {}
      PTokenDivStart {}
      PTokenDivEnd {} -> pToken
    g s =
      let attempts = map (tryRearrange s) punctCombos
       in case find (\(mightHaveMore, _) -> mightHaveMore) attempts of
            Just (_, s') -> g s'
            Nothing -> s
    tryRearrange s punctCombo
      | (prefix, remainder) <- T.breakOn punctCombo s,
        not (T.null remainder) =
          ( True,
            prefix
              <> T.reverse punctCombo
              <> T.drop (T.length punctCombo) remainder
          )
      | otherwise = (False, s)
    punctCombos :: [Text]
    punctCombos =
      [ "\".",
        "\",",
        "'.",
        "',"
      ]

We don't support inNote. CslJson doesn't support it either.

instance:CiteprocOutput Prose:inNote
inNote = id

mapText just applies an arbitrary function to all Text values.

instance:CiteprocOutput Prose:mapText
mapText f prose = Prose $ map g prose.unProse
  where
    g :: PToken -> PToken
    g pToken = case pToken of
      PTokenStyledText styledText ->
        PTokenStyledText
          StyledText
            { body = f styledText.body,
              style = styledText.style
            }
      PTokenLink {}
      PTokenCitation {}
      PTokenMathFragment {}
      PTokenDivStart {}
      PTokenDivEnd {} -> pToken

addHyperlink just adds a URL; we have a rich data:Link type for this, so that's what we use.

We don't support localization of quotes.

instance:CiteprocOutput Prose:localizeQuotes
localizeQuotes _ prose = prose

7.2 Tests

We need to check that there is no information loss going the full round trip from Text to Prose and back to Text again.

f:checkTextToProseRoundTrip
checkTextToProseRoundTrip :: String -> [(Text, Prose)] -> Spec
checkTextToProseRoundTrip msg testCases = do
  it msg $ do
    forM_ testCases $ \(text, prose) -> do
      CP.fromText text `shouldBe` prose
      CP.toText prose `shouldBe` text
Text to Prose round trip
describe "Text to Prose round trip" $ do
  checkTextToProseRoundTrip
    "can handle StyledText"
    [ ( "no style",
        Prose [ptext "no style" []]
      ),
      ( "*bold*",
        Prose [ptext "bold" [Bold]]
      ),
      ( "*bo\\*ld (escaped)*",
        Prose [ptext "bo*ld (escaped)" [Bold]]
      ),
      ( "/italic/",
        Prose [ptext "italic" [Italic]]
      ),
      ( "/it\\/alic (escaped)/",
        Prose [ptext "it/alic (escaped)" [Italic]]
      ),
      ( "_underline_",
        Prose [ptext "underline" [Underline]]
      ),
      ( "_under\\_line (escaped)_",
        Prose [ptext "under_line (escaped)" [Underline]]
      ),
      ( "=*/_~verbatim=",
        Prose [ptext "*/_~verbatim" [Verbatim]]
      ),
      ( "~*/_=code~",
        Prose [ptext "*/_=code" [Code]]
      )
    ]
  checkTextToProseRoundTrip
    "can handle OID links"
    [ ( "[[id:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1]]",
        Prose
          [ PTokenLink
              Link
                { name = [stext "id:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1" []],
                  target =
                    LinkTargetOid
                      LinkOid
                        { oid = fromWords 0xaaaaaaaaaaaaaaaa 0xaaaaaaaaaaaaaaa1,
                          name = ""
                        }
                }
          ]
      ),
      ( "[[id:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2][*foo*]]",
        Prose
          [ PTokenLink
              Link
                { name = [stext "foo" [Bold]],
                  target =
                    LinkTargetOid
                      LinkOid
                        { oid = fromWords 0xaaaaaaaaaaaaaaaa 0xaaaaaaaaaaaaaaa2,
                          name = ""
                        }
                }
          ]
      ),
      ( "[[id:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa3::abc][/bar/]]",
        Prose
          [ PTokenLink
              Link
                { name = [stext "bar" [Italic]],
                  target =
                    LinkTargetOid
                      LinkOid
                        { oid = fromWords 0xaaaaaaaaaaaaaaaa 0xaaaaaaaaaaaaaaa3,
                          name = "abc"
                        }
                }
          ]
      )
    ]
  checkTextToProseRoundTrip
    "can handle footnote links"
    [ ( "[fn:abc]",
        Prose
          [ PTokenLink
              Link
                { name = [stext "1" []],
                  target = LinkTargetFootnote "abc"
                }
          ]
      ),
      ( "[fn:abc][fn:xyz][fn:abc]",
        Prose
          [ PTokenLink
              Link
                { name = [stext "1" []],
                  target = LinkTargetFootnote "abc"
                },
            PTokenLink
              Link
                { name = [stext "2" []],
                  target = LinkTargetFootnote "xyz"
                },
            PTokenLink
              Link
                { name = [stext "1" []],
                  target = LinkTargetFootnote "abc"
                }
          ]
      )
    ]
  checkTextToProseRoundTrip
    "can handle web links"
    [ ( "[[https://foo.org][bar]]",
        Prose
          [ PTokenLink
              Link
                { name = [stext "bar" []],
                  target =
                    LinkTargetURI
                      N.URI
                        { N.uriScheme = "https:",
                          N.uriAuthority =
                            Just
                              N.URIAuth
                                { N.uriUserInfo = "",
                                  N.uriRegName = "foo.org",
                                  N.uriPort = ""
                                },
                          N.uriPath = "",
                          N.uriQuery = "",
                          N.uriFragment = ""
                        }
                }
          ]
      ),
      ( "https://foo2.org",
        Prose
          [ PTokenLink
              Link
                { name = [stext "https://foo2.org" []],
                  target =
                    LinkTargetURI
                      N.URI
                        { N.uriScheme = "https:",
                          N.uriAuthority =
                            Just
                              N.URIAuth
                                { N.uriUserInfo = "",
                                  N.uriRegName = "foo2.org",
                                  N.uriPort = ""
                                },
                          N.uriPath = "",
                          N.uriQuery = "",
                          N.uriFragment = ""
                        }
                }
          ]
      )
    ]
  checkTextToProseRoundTrip
    "can handle file links"
    [ ( "[[file:foo/bar]]",
        Prose
          [ PTokenLink
              Link
                { name = [stext "foo/bar" []],
                  target = LinkTargetFilePath "foo/bar"
                }
          ]
      ),
      ( "[[file:foo/bar][baz]]",
        Prose
          [ PTokenLink
              Link
                { name = [stext "baz" []],
                  target = LinkTargetFilePath "foo/bar"
                }
          ]
      )
    ]
  checkTextToProseRoundTrip
    "can handle inline math fragments"
    [ ( "\\(4 = 2 * 2\\)",
        Prose
          [ PTokenMathFragment
              MathFragment
                { parentOid = nilOid,
                  body = "4 = 2 * 2",
                  meta = fromList []
                }
          ]
      )
    ]

We can't check for non-inline math fragments in the test above, because f:proseParser only allows for inline math fragments.

Check we can drop text characters from either end of newtype:Prose.

Drop text chars from Prose
describe "Drop text chars from Prose" $ do
  it "can handle plain text from the front" $ do
    CP.dropTextWhile (== 'a') (CP.fromText "aa b *cc*" :: Prose)
      `shouldBe` (CP.fromText " b *cc*")
  it "can handle plain text from the back" $ do
    CP.dropTextWhileEnd (== 'c') (CP.fromText "aa b *cc*" :: Prose)
      `shouldBe` (CP.fromText "aa b ")
  it "can handle styled (italic) text from the front" $ do
    CP.dropTextWhile (== 'a') (CP.fromText "/aa/ b *cc*" :: Prose)
      `shouldBe` (CP.fromText " b *cc*")
  it "can handle styled (italic) text from the back" $ do
    CP.dropTextWhileEnd (== 'c') (CP.fromText "/aa/ b *cc*" :: Prose)
      `shouldBe` (CP.fromText "/aa/ b ")
  it "skips over non-text elements from the front" $ do
    CP.dropTextWhile (== 'a') (CP.fromText "[[foo]]/aa/ b *cc*" :: Prose)
      `shouldBe` (CP.fromText "[[foo]] b *cc*")
  it "skips over non-text elements from the back" $ do
    CP.dropTextWhileEnd (== 'c') (CP.fromText "/aa/ b *cc*[[foo]]" :: Prose)
      `shouldBe` (CP.fromText "/aa/ b [[foo]]")

8 Paragraphs

The f:paragraphParser is similar to f:headingParser because it's also powered mostly by f:proseParser. However a key difference is that it is aware of the nuances between newlines, indentation, and spaces.

The main concern is to understand where the paragraph starts. Paragraphs can start in three ways:

  1. without indentation,

  2. after a list head (which is essentially indentation), or

  3. with indentation (as part of a list).

In all three cases, we would like to check whether a paragraph's inner lines have consistent indentation. This way, a paragraph's lines all line up vertically on the same column, and is visually pleasing. Doing this check means understanding what is the expected indentation (if at all) for lines inside the paragraph.

f:paragraphParser
paragraphParser :: OrgParser Cell
paragraphParser = do
  pState <- get
  modify (\s -> s {insideParagraph = True, glossaryTermOffset = 0})
  prose <-
    proseParser
      . whitespaceCompressor
      $ proseSpaceConsumer pState.continuationIndent -- 102
  failIfLeftoverActiveStyles
  pState' <- get
  modify (\s -> s {insideParagraph = False})
  if pState'.glossaryTermOffset > 0
    then convertToGlossaryTerm prose pState'.glossaryTermOffset -- 103
    else pure . CParagraph $ compressProse prose

The check at 103 is for seeing if the current paragraph qualifies as a glossary term. If not, we parse it as a normal paragraph.

The f:proseSpaceConsumer 102 knows how to parse spaces across lines (while taking into account the existing level of indentation in the current line, if any). This is a top-level function because we reuse it in f:linkDisplayNameParser.

f:proseSpaceConsumer
proseSpaceConsumer :: Int -> OrgParser Text
proseSpaceConsumer expectedIndentLen = do
  _ <- beLessGreedy
  spaceConsumer
  where
    spaceConsumer :: OrgParser Text
    spaceConsumer =
      MP.choice
        [ newlineAndIndentParser expectedIndentLen,
          onlySpaces
        ]
    beLessGreedy :: OrgParser ()
    beLessGreedy = do
      pState <- get
      let toAvoid =
            [ (pState.insideCitePrefix, spaceConsumer >> citeIdParser)
            ]
      forM_
        toAvoid
        $ \(condition, p) -> when condition $ MP.notFollowedBy p

There are a couple tricky bits here. The first tricky part is with the beLessGreedy helper. It's important that the spaceConsumer does not steal either the space or newline just preceding the f:citeIdParser; otherwise, the citePrefixParser in f:citeParser will never succeed because it requires some whitespace, only after which we parse the citeId. This is why beLessGreedy tries to abort the parser if it detects a cite key like @foo just after the whitespace (which will be parsed by spaceConsumer).

The second tricky part is with spaceConsumer. It chooses either a newline followed by an indent of length expectedIndentLen (in f:newlineAndIndentParser), or just spaces. The f:newlineAndIndentParser is what ensures we have consistent indentation for lines of a paragraph.

f:newlineAndIndentParser
newlineAndIndentParser :: Int -> OrgParser Text
newlineAndIndentParser expectedIndentLen = do
  MP.notFollowedBy cellBoundary -- 104
  _ <- string "\n"
  maybeIndent -- 105
  pure " "
  where
    maybeIndent =
      if (expectedIndentLen == 0)
        then nonSpace
        else MP.eof <|> exactIndent
    nonSpace :: OrgParser ()
    nonSpace = MP.notFollowedBy (char ' ')
    exactIndent :: OrgParser ()
    exactIndent = do
      _ <-
        MP.label "indentation for paragraph continuation"
          $ MP.count expectedIndentLen (char ' ')
      MP.notFollowedBy (char ' ')
      MP.notFollowedBy (char '\n')
      pure ()

The MP.notFollowedBy cellBoundary bit 104 is required to stay clear of cell boundaries (paragraph cells are separated from each other by a blank line, or in other words a sequence of two consecutive newlines). If we don't do this, our parser will see a newline and immediately think that there should follow some spaces for the indent portion, which may not be true if the newline was part of a cell boundary.

f:cellBoundary
cellBoundary :: OrgParser Text
cellBoundary = string "\n\n"

The maybeIndent 105 handles the case where there is no indentation (with nonSpace), or where there is indentation (with MP.eof <|> exactIndent). The first case does not need MP.eof <|> because it's redundant --- MP.notFollowedBy (char ' ') will succeed for MP.eof as well, as t:0005-notFollowedBy-behavior-for-empty-input demonstrates.

t:0005-notFollowedBy-behavior-for-empty-input
it "accepts empty input for \"notFollowedBy (char ' ')\"" $ do
  MP.parse
    (MP.notFollowedBy (char ' ') :: Parser ())
    "empty input"
    `shouldSucceedOn` ""

8.1 Tests

Below we check several types of paragraphs without regard to indentation.

ti:0005-prose-paragraphs
🎯 test/Lilac/ParseSpec/0005-prose-paragraphs
a b  c   d    e      *f g  h   i    j*

x y
z foo
bar

_multiple
lines
with
underscore_
t:0005-prose-paragraphs
shouldParseWith
  "0005-prose-paragraphs"
  $ nilOrgDoc
    Expected prose for 0005-prose-paragraphs
    []
Expected prose for 0005-prose-paragraphs
[ CParagraph
    $ Prose
      [ ptext "a b c d e " [],
        ptext "f g h i j" [Bold]
      ],
  CParagraph $ Prose [ptext "x y z foo bar" []],
  CParagraph $ Prose [ptext "multiple lines with underscore" [Underline]]
]

The paragraphs in the test case above all start without any indentation. This is because indentation is invalid for standalone paragraphs that are not part of a list in some way.

ti:0008-indentation-paragraph
🎯 test/Lilac/ParseSpec/0008-indentation-paragraph
 foo bar
t:0008-indentation-paragraph
shouldFailWithMessage
  "0008-indentation-paragraph"
  "invalid indentation"
  1

For tests involving paragraphs and lists, see those prefixed with 0005-paragraph-indentation-*, such as ti:0005-paragraph-indentation-mismatch-over.

9 Headings

Headings start with asterisks without any indentation, followed by some prose text (see the discussion of prose text).

ti:0008-indentation-headings
🎯 test/Lilac/ParseSpec/0008-indentation-headings
 * space before star is not allowed for headings
t:0008-indentation-headings
shouldFailWithMessage
  "0008-indentation-headings"
  "invalid indentation"
  1

The number of asterisks determine the nesting level. By default there can be only 3 levels (maximum of 3 asterisks for a heading), although you can raise this number with Lilac extensions). Still, there should be probably no more than 6 asterisks for a heading because HTML heading tags <h1>, <h2>, etc only go up to <h6>). This Org document itself only uses 3 sections.

The heading may also have some metadata associated with it. In practice this will be keys and values from a :PROPERTIES: drawer, although other drawers are possible. Drawers are discussed separately here.

We store drawer information inside the meta hashmap 106, with the drawer name acting as a key prefix (this way we can store multiple drawers, if there are any, in a single hashmap).

Other than the level, each heading is made up of Prose text.

data:Heading
type Heading :: Type
data Heading = Heading
  { level :: Int,
    section :: [Int],
    body :: Prose,
    meta :: Map Text Text -- 106
  }
  deriving stock (Eq, Ord, Generic, Show)
  deriving anyclass (FromJSON, ToJSON)

The section field is generated during the parse, and is the section number like 1.1.2 or 5.3, represented as a list of integers. The numbering scheme matches the behavior of org-num-mode, so it is highly recommended to turn that on in Emacs. The value at the head of the list represents the deepest level (it's reversed); for example, section 5.3 is represented as [3, 5].

The parser for a heading is straightforward as the strict requirements make it easy to have simple rules for it.

f:headingParser
headingParser :: OrgParser Cell
headingParser = do
  pState <- get
  _ <- MP.try $ MP.lookAhead (starsParser >> char ' ')
  let opc = pState.orgParserConf
  stars <- starsParser
  when (opc.maxHeadingLevel < 0) $ do
    fail
      $ "headings prohibited because lilac_maxHeadingLevel < 0"
  let lenStars = T.length stars
      currentHeadingLevel = pState.headingLevel
  when (opc.maxHeadingLevel > 0 && lenStars > opc.maxHeadingLevel) $ do
    fail
      $ "found "
      <> show lenStars
      <> " asterisks found for this heading, but "
      <> "lilac_maxHeadingLevel = "
      <> show opc.maxHeadingLevel
  failIfHeadingNestsWithoutDirectParent currentHeadingLevel lenStars
  _ <- char ' '
  modify (\s -> s {insideHeading = True})
  prose <- proseParser $ whitespaceCompressor onlySpaces
  _ <- char '\n'
  failIfLeftoverActiveStyles
  props <- propertiesDrawerParser
  let newHeadingSection = genSectionNumber lenStars pState.headingSectionNumber
      newHeadingOidStack =
        genHeadingOidStack
          (lookup "ID" props)
          lenStars
          pState.headingOidStack
  put
    pState
      { headingLevel = lenStars,
        headingSectionNumber = newHeadingSection,
        headingOidStack = newHeadingOidStack,
        insideHeading = False
      }
  pure
    $ CHeading
      Heading
        { level = lenStars,
          section = newHeadingSection,
          body = compressProse prose,
          meta = props
        }
  where
    starsParser :: OrgParser Text
    starsParser = MP.takeWhile1P (Just "heading star") (\c -> c == '*')

We fail if the heading level is not compliant with the setting of lilac_maxHeadingLevel, which is accessed via opc.maxHeadingLevel (see Lilac extensions).

We also fail if we try to nest the heading too much in f:failIfHeadingNestsWithoutDirectParent. See ti:0004-headings-cannot-skip-level for an example.

f:failIfHeadingNestsWithoutDirectParent
failIfHeadingNestsWithoutDirectParent :: Int -> Int -> OrgParser ()
failIfHeadingNestsWithoutDirectParent current found =
  when (found > current + 1) $ do
    fail
      $ "headings may not skip levels (expected to see level "
      <> show current
      <> " or "
      <> (show $ current + 1)
      <> ", not "
      <> show found
      <> ")"

9.1 Section number generation

Generating the section number relies on the previous section, and the current level of the heading for which we want to generate the section number.

f:genSectionNumber
genSectionNumber :: Int -> [Int] -> [Int]
genSectionNumber level prevSection
  | null prevSection = 1 : (take (level - 1) $ repeat 0)
  | length prevSection == level = bumpDeepestSection prevSection
  | length prevSection > level =
      bumpDeepestSection
        $ drop (length prevSection - level) prevSection
  | otherwise =
      let levelDiff = (level - length prevSection - 1)
       in (1 : (take levelDiff $ repeat 0)) <> prevSection -- 107
  where
    bumpDeepestSection :: [Int] -> [Int]
    bumpDeepestSection [] = []
    bumpDeepestSection (x : xs) = x + 1 : xs

The algorithm starts with the base case where we have no previous section (null prevSection), where we assign "0" section numbers for the missing levels, if any. The same thing applies for the case where we nest too deeply than a previously seen level 107.

Although f:genSectionNumber takes into account these edge cases where we have a "0" section number, the parser itself fails if we skip any levels (if we nest too deeply at any point), in f:failIfHeadingNestsWithoutDirectParent.

t:Generate headings
describe "genSectionNumber" $ do
  it "gets the base case" $ do
    L.genSectionNumber 2 [] `shouldBe` [1, 0]
    L.genSectionNumber 1 [] `shouldBe` [1]
    L.genSectionNumber 0 [] `shouldBe` [1]
    L.genSectionNumber (-1) [] `shouldBe` [1]
  it "bumps the last level if the nesting level matches" $ do
    L.genSectionNumber 1 [1] `shouldBe` [2]
    L.genSectionNumber 2 [1, 1] `shouldBe` [2, 1]
    L.genSectionNumber 3 [1, 1, 1] `shouldBe` [2, 1, 1]
  it "truncates, then increments the (newst) deepest level if we unnest" $ do
    L.genSectionNumber 1 [3, 5] `shouldBe` [6]
    L.genSectionNumber 3 [3, 5, 6, 7] `shouldBe` [6, 6, 7]
  it "grows the section if we nest deeper" $ do
    L.genSectionNumber 3 [2, 2] `shouldBe` [1, 2, 2]
    L.genSectionNumber 5 [2, 2] `shouldBe` [1, 0, 0, 2, 2]

9.2 Generating the current Org ID

f:genHeadingOidStack is quite similar to f:genSectionNumber. The point of it is to store the Org IDs up to the current point. This is ultimately used to assign the OID of the first parent heading (if any) for an implicitly-defined code block, in f:oidReferenceParser. If there are no headings, then it defaults to the top-level (required) OID of the document.

f:genHeadingOidStack
genHeadingOidStack :: Maybe Text -> Int -> [Maybe OID] -> [Maybe OID]
genHeadingOidStack maybeId level old
  | length old == 0 = [newId]
  | length old == level = newId : drop 1 old
  | length old < level =
      let nothings = take (levelDiff - 1) $ repeat Nothing
       in (newId : nothings) <> old
  | otherwise = newId : (drop (abs levelDiff + 1) old)
  where
    levelDiff = level - length old
    newId = Lilac.Types.fromText =<< maybeId
t:Generate Org ID stack
describe "genHeadingOidStack" $ do
  it "replaces the head if the nesting level matches (no heading yet)" $ do
    L.genHeadingOidStack
      (Just "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
      1
      [Nothing]
      `shouldBe` [Just $ fromWords 0xaaaaaaaaaaaaaaaa 0xaaaaaaaaaaaaaaaa]
  it "replaces the head if the nesting level matches (one heading exists)" $ do
    L.genHeadingOidStack
      (Just "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
      1
      [Just $ fromWords 0x7777777777777777 0x7777777777777777]
      `shouldBe` [Just $ fromWords 0xaaaaaaaaaaaaaaaa 0xaaaaaaaaaaaaaaaa]
  it "adds a new head if heading nests (new heading has an ID)" $ do
    L.genHeadingOidStack
      (Just "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
      2
      [Nothing]
      `shouldBe` [ Just $ fromWords 0xaaaaaaaaaaaaaaaa 0xaaaaaaaaaaaaaaaa,
                   Nothing
                 ]
  it "adds a new head if heading nests (new heading does not have an ID)" $ do
    L.genHeadingOidStack
      Nothing
      2
      [Nothing]
      `shouldBe` [ Nothing,
                   Nothing
                 ]
  it "truncates if we unnest (difference of 1 level)" $ do
    L.genHeadingOidStack
      (Just "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
      1
      [Just $ fromWords 0x7777777777777777 0x7777777777777777, Nothing]
      `shouldBe` [Just $ fromWords 0xaaaaaaaaaaaaaaaa 0xaaaaaaaaaaaaaaaa]
  it "truncates if we unnest (multiple levels)" $ do
    L.genHeadingOidStack
      Nothing
      1
      [Nothing, Nothing, Just $ fromWords 0xaaaaaaaaaaaaaaaa 0xaaaaaaaaaaaaaaaa]
      `shouldBe` [Nothing]
  it "truncates if we unnest (multiple levels, new heading has ID)" $ do
    L.genHeadingOidStack
      (Just "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
      1
      [Nothing, Nothing, Just $ fromWords 0x7777777777777777 0x7777777777777777]
      `shouldBe` [Just $ fromWords 0xaaaaaaaaaaaaaaaa 0xaaaaaaaaaaaaaaaa]

9.3 Tests

By default we only allow 3 levels of headings.

ti:0004-headings
🎯 test/Lilac/ParseSpec/0004-headings
* 1
** 2
*** 3
**** 4
***** 5
****** 6
******* 7
t:0004-headings
shouldFailWithMessage
  "0004-headings"
  "found 4 asterisks found for this heading, but lilac_maxHeadingLevel = 3"
  19

We can have many levels of headings beyond the 3 that we have by default, by setting the lilac_maxHeadingLevel metadata. We can set it to some high number, or set it to 0 (unlimited). First we demonstrate using a higher number, 20.

ti:0004-headings-20
🎯 test/Lilac/ParseSpec/0004-headings-20
#+lilac_maxHeadingLevel: 20
* 1
** 2
*** 3
**** 4
***** 5
****** 6
******* 7
******** 8
********* 9
********** 10
*********** 11
************ 12
t:0004-headings-20
shouldParseWithState
  "0004-headings-20"
  ( nilOrgDoc
      [ plainHeading 1 [1],
        plainHeading 2 [1, 1],
        plainHeading 3 [1, 1, 1],
        plainHeading 4 [1, 1, 1, 1],
        plainHeading 5 [1, 1, 1, 1, 1],
        plainHeading 6 [1, 1, 1, 1, 1, 1],
        plainHeading 7 [1, 1, 1, 1, 1, 1, 1],
        plainHeading 8 [1, 1, 1, 1, 1, 1, 1, 1],
        plainHeading 9 [1, 1, 1, 1, 1, 1, 1, 1, 1],
        plainHeading 10 [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
        plainHeading 11 [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
        plainHeading 12 [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
      ]
      []
  )
  defaultOrgParserState
    { headingOidStack = take 12 $ repeat Nothing,
      headingLevel = 12,
      headingSectionNumber = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
      orgParserConf = L.defaultOrgParserConf {maxHeadingLevel = 20}
    }

Now we check using 0 for unlimited nesting.

ti:0004-headings-unlimited
🎯 test/Lilac/ParseSpec/0004-headings-unlimited
#+lilac_maxHeadingLevel: 0
* 1
** 2
*** 3
**** 4
***** 5
****** 6
******* 7
******** 8
********* 9
********** 10
*********** 11
************ 12
t:0004-headings-unlimited
shouldParseWithState
  "0004-headings-unlimited"
  ( nilOrgDoc
      [ plainHeading 1 [1],
        plainHeading 2 [1, 1],
        plainHeading 3 [1, 1, 1],
        plainHeading 4 [1, 1, 1, 1],
        plainHeading 5 [1, 1, 1, 1, 1],
        plainHeading 6 [1, 1, 1, 1, 1, 1],
        plainHeading 7 [1, 1, 1, 1, 1, 1, 1],
        plainHeading 8 [1, 1, 1, 1, 1, 1, 1, 1],
        plainHeading 9 [1, 1, 1, 1, 1, 1, 1, 1, 1],
        plainHeading 10 [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
        plainHeading 11 [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
        plainHeading 12 [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
      ]
      []
  )
  defaultOrgParserState
    { headingOidStack = take 12 $ repeat Nothing,
      headingLevel = 12,
      headingSectionNumber = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
      orgParserConf = L.defaultOrgParserConf {maxHeadingLevel = 0}
    }

Here we set it to a number less than 3. This is useful for ensuring that we don't have too much nesting.

ti:0004-headings-limited
🎯 test/Lilac/ParseSpec/0004-headings-limited
#+lilac_maxHeadingLevel: 2
* a
** b
*** c
**** d
t:0004-headings-limited
shouldFailWithMessage
  "0004-headings-limited"
  "found 3 asterisks found for this heading, but lilac_maxHeadingLevel = 2"
  39

We can also prohibit headings entirely, by setting lilac_maxHeadingLevel to -1.

ti:0004-headings-prohibited
🎯 test/Lilac/ParseSpec/0004-headings-prohibited
#+lilac_maxHeadingLevel: -1
* a
**** d
t:0004-headings-prohibited
shouldFailWithMessage
  "0004-headings-prohibited"
  "headings prohibited because lilac_maxHeadingLevel < 0"
  29

We may not skip over any intermediate levels when we nest. For example, you cannot go from level 1 to level 4 (skipping over levels 2 and 3).

ti:0004-headings-cannot-skip-level
🎯 test/Lilac/ParseSpec/0004-headings-cannot-skip-level
#+lilac_maxHeadingLevel: 6
* a
**** d
t:0004-headings-cannot-skip-level
shouldFailWithMessage
  "0004-headings-cannot-skip-level"
  "headings may not skip levels (expected to see level 1 or 2, not 4)"
  35

10 Drawers

In vanilla Orgmode, drawers look like this:

:DRAWER_NAME:
...contents...
:END:

And Orgmode allows the contents to be anything except headings or other (nested) drawers.

In Lilac, we only parse the :PROPERTIES: drawer, which is a special drawer that can contain key/value pairs that Org recognizes. One of these is the :ID: key, which can have an OID as the value, and which can be used as a link target from elsewhere in the document (in Orgmode this can be done with org-id-store-link).

f:propertiesDrawerParser
propertiesDrawerParser :: OrgParser (Map Text Text)
propertiesDrawerParser = propertiesParser <|> (pure $ fromList [])

The f:propertiesDrawerParser tries to parse a :PROPERTIES: drawer with f:propertiesParser, but if it fails it returns an empty Map.

f:propertiesParser
propertiesParser :: OrgParser (Map Text Text)
propertiesParser =
  fromList
    <$> MP.between
      (lexeme $ string ":PROPERTIES:\n")
      (string ":END:\n")
      (many $ lexeme propertiesEntryParser)

The f:propertiesParser is just a thin wrapper around f:propertiesEntryParser which does the heavy lifting of figuring out the key value parts.

The uses of f:lexeme allow this parser to ignore comments it finds inside the drawer area. There is no need to use f:lexeme for the closing :END: bit because we already wrap all cells with f:lexeme in f:cellParser, and the last thing f:headingParser parses is f:propertiesParser (if f:headingParser parsed something else after calling f:propertiesParser, we would need to wrap :END: with f:lexeme as well).

f:propertiesEntryParser
propertiesEntryParser :: OrgParser (Text, Text)
propertiesEntryParser = do
  MP.notFollowedBy $ string ":END:"
  _ <- char ':'
  key <- MP.label "property key" . MP.some $ MP.noneOf ['\n', ':', ' ']
  _ <- char ':'
  _ <- onlySpaces
  value <-
    MP.manyTill
      (MP.label "property value" MP.anySingle)
      (char '\n')
  pure (T.pack key, T.pack value)

The main tricky bit is MP.notFollowedBy $ string ":END:"; we need this because otherwise we will think that the string :END: is just another property key.

10.1 Tests

The :PROPERTIES: drawer may be empty.

ti:0004-properties-drawer-empty
🎯 test/Lilac/ParseSpec/0004-properties-drawer-empty
* foo
:PROPERTIES:
:END:
t:0004-properties-drawer-empty
shouldParseWithState
  "0004-properties-drawer-empty"
  ( nilOrgDoc
      [ CHeading
          Heading
            { level = 1,
              section = [1],
              body = Prose [ptext "foo" []],
              meta = fromList []
            }
      ]
      []
  )
  defaultOrgParserState
    { headingOidStack = [Nothing],
      headingLevel = 1,
      headingSectionNumber = [1]
    }

Check we can pick up keys and values from the :PROPERTIES: drawer.

ti:0004-properties-drawer-not-empty
🎯 test/Lilac/ParseSpec/0004-properties-drawer-not-empty
* foo
:PROPERTIES:
:ID:       b949ea2e-e6d1-453d-9352-5e85a82cd4e6
:FOO:      BAR
:END:
t:0004-properties-drawer-not-empty
shouldParseWithState
  "0004-properties-drawer-not-empty"
  ( nilOrgDoc
      [ CHeading
          Heading
            { level = 1,
              section = [1],
              body = Prose [ptext "foo" []],
              meta =
                fromList
                  [ ("ID", "b949ea2e-e6d1-453d-9352-5e85a82cd4e6"),
                    ("FOO", "BAR")
                  ]
            }
      ]
      []
  )
  defaultOrgParserState
    { headingOidStack =
        [Just $ fromWords 0xb949ea2ee6d1453d 0x93525e85a82cd4e6],
      headingLevel = 1,
      headingSectionNumber = [1]
    }

Adding comments inside drawers is allowed (we ignore them because we use f:lexeme in f:propertiesParser).

ti:0004-properties-drawer-not-empty-with-comments
🎯 test/Lilac/ParseSpec/0004-properties-drawer-not-empty-with-comments
* foo
:PROPERTIES:

# Comment.

:ID:       b949ea2e-e6d1-453d-9352-5e85a82cd4e6

#+begin_comment
This ":KEY:" ignored because it's inside a comment block.
:KEY: value
#+end_comment

:END:
t:0004-properties-drawer-not-empty-with-comments
shouldParseWithState
  "0004-properties-drawer-not-empty-with-comments"
  ( nilOrgDoc
      [ CHeading
          Heading
            { level = 1,
              section = [1],
              body = Prose [ptext "foo" []],
              meta =
                fromList
                  [ ("ID", "b949ea2e-e6d1-453d-9352-5e85a82cd4e6")
                  ]
            }
      ]
      []
  )
  defaultOrgParserState
    { headingOidStack =
        [Just $ fromWords 0xb949ea2ee6d1453d 0x93525e85a82cd4e6],
      headingLevel = 1,
      headingSectionNumber = [1]
    }

11 Blocks

Blocks are regions of text that start with #+begin<...> and end with #+end<...>. They are used to delineate source code (such as those used for Noweb references), examples, quotes, and others. Some examples are src or example (as in #+begin_src and #+begin_example), although technically Org allows for any arbitrary keyword here.

data:Block
type Block :: Type
data Block = Block
  { parentOid :: OID, -- 108
    meta :: Map Text Text, -- 109
    body :: [BToken] -- 110
  }
  deriving stock (Eq, Ord, Generic, Show)
  deriving anyclass (FromJSON, ToJSON)

The type of block (such as src or example as discussed above) is stored in the metadata map meta 109, with the __block_type key. See Expected blocks for 0007-blocks-empty for examples, as well as the Metadata section for more a more detailed discussion.

The parentOid 108 is the closest heading which has a :ID: property (aka OID), or the OID of the document if none of the headings have an OID (or if there are simply no headings preceding the block). Using Emacs functions like org-id-store-link will generate the OID automatically for the parent heading (creating a property drawer, adding the :ID: key, etc) if it doesn't already have an :ID:.

We need to store the parentOid here because ultimately, we need to build up a hashmap of symbols in f:mkSymbols, which uses the parentOid as a building block. Once we have all the symbols (using parentOid fields) defined such that we know what they point to, we are able to look at a data:LinkOid and are able to determine what that link actually points to (whether it's a block, table, figure, etc). This allows us to know how to weave these links (specifically, this information about symbols lets us know how to weave the destination, because different cells have different link anchors --- see f:weaveLinkTarget for details). All cells that can be named (not just blocks, such as tables and figures as stated above) have an equivalent parentOid field to assist with this, for this same reason.

Lastly, the body field 110 stores the actual contents of the code block. Briefly, the body can have either text or links to other code blocks --- also known as Noweb references. See Body for details.

11.1 Metadata

There are two kinds of metadata formats that blocks accept:

  1. The #+key: value format. The most common (and probably important) one is the #+name: ... metadata, as it allows blocks to be referenced by other blacks by its name. The generic f:metaParser is used to parse these values (not just for blocks but also for Figures).

  2. The #+header: :key value format. The most common ones here is #+header: :tangle ... which specifies the tangle path of the code block. f:blockMetaEntryParser is used to parse these ones.

The reason why blocks have two separate formats like this is because that's what Orgmode uses as well. The test case below has some examples of valid (empty) code blocks with some typical metadata.

ti:0007-blocks-empty
🎯 test/Lilac/ParseSpec/0007-blocks-empty
#+begin_src haskell
#+end_src

#+name: foo
#+begin_example
#+end_example

#+begin_quote
#+end_quote

#+header: :tangle foo.hs
#+begin_src haskell

#+end_src
t:0007-blocks-empty
shouldParseWithState
  "0007-blocks-empty"
  ( nilOrgDoc
      Expected blocks for 0007-blocks-empty
      []
  )
  defaultOrgParserState
    { cellNames = Set.fromList ["foo"],
      anonBlockNumber = 3,
      tanglePaths = fromList ["foo.hs"]
    }
Expected blocks for 0007-blocks-empty
[ CBlock
    Block
      { parentOid = nilOid,
        meta =
          fromList
            [ ("__anon_block_number", "1"),
              ("__block_type", "src"),
              ("__src_language", "haskell")
            ],
        body = []
      },
  CBlock
    Block
      { parentOid = nilOid,
        meta =
          fromList
            [ ("__block_type", "example"),
              ("name", "foo")
            ],
        body = []
      },
  CBlock
    Block
      { parentOid = nilOid,
        meta =
          fromList
            [ ("__anon_block_number", "2"),
              ("__block_type", "quote")
            ],
        body = []
      },
  CBlock
    Block
      { parentOid = nilOid,
        meta =
          fromList
            [ ("__anon_block_number", "3"),
              ("__block_type", "src"),
              ("__src_language", "haskell"),
              ("tangle", "foo.hs")
            ],
        body = []
      }
]

The subsections below discuss each of the different types of metadata parsers.

11.1.1 Generic metadata

The generic f:metaParser is used in f:blockStartParser 122 so that we can check for certain requirements for metadata syntax across multiple cell types that use metadata. For example, the #+name: value cannot start with an open parenthesis.

The metadata is parsed with f:metaParser which relies on f:metaEntryParser. We make sure that the name given to the cell is unique inside the document being parsed, because ultimately these cell names determine how we generate HTML IDs for each cell (although we could give machine-generated ID names, we prefer to use the name if one exists because they are usually much more human-friendly).

f:metaParser
metaParser :: OrgParser (Map Text Text)
metaParser = do
  metaKVs <- many $ lexeme metaEntryParser
  let metaKVsMap = fromList metaKVs
  case lookup "name" metaKVsMap of
    Just cellName -> assertUniqueCellName cellName
    Nothing -> pure ()
  pure metaKVsMap

The f:assertUniqueCellName function keeps track of all the named cells we've seen so far. This is used to detect whether we've seen a cell name more than once, which is not allowed.

f:assertUniqueCellName
assertUniqueCellName :: Text -> OrgParser ()
assertUniqueCellName cellName = do
  pState <- get
  when (Set.member cellName pState.cellNames)
    . fail
    $ "cell name "
    <> show cellName
    <> " is not unique"
  put
    pState
      { cellNames = Set.insert cellName pState.cellNames
      }
ti:0007-blocks-duplicate-names
🎯 test/Lilac/ParseSpec/0007-blocks-duplicate-names
#+name: foo
#+begin_src sh
a
#+end_src

#+name: foo
#+begin_src sh
b
#+end_src
t:0007-blocks-duplicate-names
shouldFailWithMessage
  "0007-blocks-duplicate-names"
  "cell name \"foo\" is not unique"
  52

The f:metaEntryParser is similar to f:docMetaEntryParser. In the latter, we use avoidParsingBlockMetadata 58 to avoid touching on the toes of f:blockMetaEntryParser, and similarly we have to check that each metadata line does not start with #+begin_ 111 because otherwise this will be too greedy and rob f:blockStartParser the chance to see the #+begin_ string to denote the start of the block.

f:metaEntryParser
metaEntryParser :: OrgParser (Text, Text)
metaEntryParser = do
  mapM_
    MP.notFollowedBy
    $ map
      string
      [ "#+begin_", -- 111
        "#+header:",
        "#+print_bibliography:",
        "#+print_glossary:"
      ]
  _ <- string "#+"
  o <- MP.getOffset
  key <- MP.takeWhile1P (Just "(meta) keyword letter") isKeywordLetter
  _ <- MP.label "(meta) separator" $ string ": "
  _ <- many $ char ' '
  value <-
    if key == "name"
      then do
        first <- MP.satisfy (\c -> c /= '(' && isValueLetter c) -- 112
        rest <- MP.takeWhileP (Just "(meta) value letter") isValueLetter
        pure $ T.cons first rest
      else MP.takeWhile1P (Just "(meta) value letter") isValueLetter
  when (T.isPrefixOf "__" key) -- 113
    $ do
      MP.setOffset o
      fail
        $ "cannot use leading double underscores for metadata key: "
        <> show key
  pure (key, value)
  where
    isKeywordLetter c = or $ map ($ c) [isAsciiLower, isAsciiUpper, (== '_')]
    isValueLetter c = c /= '\n'

For the #+name key, we prohibit the use of the opening parenthesis as the first character of the name at 112, because then that would make [[(foo)]] ambiguous (it could refer to either something named (foo), or it could refer to a line label (ref:foo).

ti:0007-blocks-no-name-with-opening-parenthesis
🎯 test/Lilac/ParseSpec/0007-blocks-no-name-with-opening-parenthesis
#+name: (a)
#+begin_src sh
a
#+end_src
t:0007-blocks-no-name-with-opening-parenthesis
shouldFailWithError
  "0007-blocks-no-name-with-opening-parenthesis"
  (utoks "(" <> etoks " ")
  8

11.1.2 #+header: ... metadata

Vanilla Org allows you to place these #+header: ... key/value pairs on the same line as the #+begin_... line, like this:

#+begin_src haskell :tangle foo
#+end_src

However for Lilac, we don't allow this. The :tangle foo must be written on its own line as #+header: :tangle foo. This enforces a cleaner (albeit verbose) style and also simplifies the parser.

f:blockMetaEntryParser
blockMetaEntryParser :: OrgParser (Text, Text)
blockMetaEntryParser = do
  _ <- string "#+header: :"
  key <- MP.takeWhile1P (Just "(block meta) keyword letter") isKeywordLetter
  _ <- char ' '
  o <- MP.getOffset
  value <- MP.takeWhile1P (Just "value letter") (\c -> c /= '\n')
  _ <- char '\n'
  assertUniqueTanglePath key value o -- 114
  pure (key, value)
  where
    isKeywordLetter c = or $ map ($ c) [isAsciiLower, isAsciiUpper, (== '-')] -- 115

The keyword (isKeywordLetter 115) cannot have underscores in them. This way, it is impossible to conflict with the keywords used internally by Lilac such as __block_type, __src_language, and so forth as seen in Expected blocks for 0007-blocks-empty.

The f:assertUniqueTanglePath function 114 checks that all tangle paths are unique; that is, no two code blocks may share the same tangle path.

f:assertUniqueTanglePath
assertUniqueTanglePath :: Text -> Text -> Int -> OrgParser ()
assertUniqueTanglePath "tangle" path offset = do
  pState <- get
  when (Set.member path pState.tanglePaths)
    $ do
      MP.setOffset $ offset
      fail $ "tangle path " <> show path <> " is not unique"
  put
    pState
      { tanglePaths = Set.insert path pState.tanglePaths
      }
assertUniqueTanglePath _ _ _ = pure ()

The tangle path is important for literate programming because it's the way in which code blocks actually get written to disk. Forcing tangle paths to be unique guarantees that there are no clashes (one block may not overwrite another).

ti:0007-blocks-duplicate-tangle-paths
🎯 test/Lilac/ParseSpec/0007-blocks-duplicate-tangle-paths
#+name: foo
#+header: :tangle some/path.sh
#+begin_src sh
a
#+end_src

#+name: bar
#+header: :tangle some/path.sh
#+begin_src sh
b
#+end_src
t:0007-blocks-duplicate-tangle-paths
shouldFailWithMessage
  "0007-blocks-duplicate-tangle-paths"
  "tangle path \"some/path.sh\" is not unique"
  101

11.2 Body

The contents or body of a data:Block is list of data:BToken values, which are quite similar to newtype:Prose (a list of data:PToken types). A BToken can be either some raw piece of text, or a link. The link is only for the case of Noweb references. For example, if a block contains a Noweb reference, then it needs to be a link such that the Noweb reference can be dereferenced, and its contents replaced with the source of the referenced code block during tangling.

data:BToken
type BToken :: Type
data BToken
  = BTokenString Text
  | BTokenPadding
  | BTokenLinkTarget LinkTarget
  deriving stock (Eq, Ord, Generic, Show)
  deriving anyclass (FromJSON, ToJSON)

f:blockParser parses the body as a list of BToken values (bTokens) 117.

f:blockParser
blockParser :: OrgParser Cell
blockParser = do
  pState <- get
  (blockName, metaKVs, blockType, srcLang) <- blockStartParser
  let nowebOff =
        (== Just "off") -- 116
          $ lookup "noweb" metaKVs
      lineLabelOff =
        (== Just "off")
          $ lookup "line-label" metaKVs
  modify (\s -> s {insideCodeBlock = True})
  bTokens <- -- 117
    MP.choice -- 118
      [ zeroBTokensParser blockType,
        someBTokensParser blockType (not nowebOff) (not lineLabelOff)
      ]
  let newAnonBlockNumber = case blockName of
        Just _ -> pState.anonBlockNumber
        Nothing -> pState.anonBlockNumber + 1
      anonBlockMeta = case blockName of -- 119
        Just _ -> Nothing
        Nothing -> Just ("__anon_block_number", T.show newAnonBlockNumber)
      block =
        Block
          { parentOid =
              firstParentOid pState.docOid pState.headingOidStack, -- 120
            meta =
              foldl'
                (\acc (k, v) -> insert k v acc)
                (insert "__block_type" blockType metaKVs)
                $ concatMap maybeToList [blockName, srcLang, anonBlockMeta],
            body =
              ignoreSpecialCommas
                . compressBTokens
                $ padConsecutiveNowebLinks bTokens -- 121
          }
  modify
    ( \s ->
        s
          { anonBlockNumber = newAnonBlockNumber,
            insideCodeBlock = False
          }
    )
  pure $ CBlock block

Before we discuss how we parse BToken values in detail, let's discuss some of the other bits first.

f:blockStartParser identifies the beginning of a block.

f:blockStartParser
blockStartParser ::
  OrgParser
    ( Maybe (Text, Text),
      Map Text Text,
      Text,
      Maybe (Text, Text)
    )
blockStartParser = do
  let blockMetaParser = many $ lexeme blockMetaEntryParser
  _ <-
    MP.try
      . MP.lookAhead
      $ do
        _ <- metaParser
        _ <- blockMetaParser
        string "#+begin_" -- 122
  metaKVs <- metaParser
  let blockName = case lookup "name" metaKVs of
        Just name -> Just ("name", name)
        Nothing -> Nothing
  blockMetaKVs <- fromList <$> blockMetaParser
  _ <- string "#+begin_"
  blockType <- MP.takeWhile1P (Just "block type") isAsciiLower
  srcLang <- MP.optional $ do
    _ <- MP.skipSome $ char ' '
    src <- MP.takeWhile1P (Just "src language") isSrcLanguageChar
    pure ("__src_language", src)
  when (blockType == "src" && srcLang == Nothing)
    $ fail "block type src requires a language specifier" -- 123
  let finalKVs =
        -- 124
        if (blockName == Just ("name", "index"))
          && (notMember "hidden" blockMetaKVs)
          then insert "hidden" "true" blockMetaKVs
          else blockMetaKVs
  _ <- char '\n' -- 125
  pure (blockName, finalKVs, blockType, srcLang)
  where
    isSrcLanguageChar c = isAsciiLower c || c == '-'

We first have to use the same try/lookAhead trick we use in f:figureParser at the beginning 122, because otherwise our parser thinks that as soon as we parse a #+name, that it will be a block. This is not true because other things (like figures) can have a #+name associated with them. We fail the parse if we detect a #+begin_src block without an accompanying language 123; see ti:0007-block-src-without-lang for an example.

124 automatically makes "index" blocks hidden, because their contents are not very interesting (they can be seen by looking at the left sidebar in the site contents).

Back to f:blockParser. The nowebOff bit 116 controls how we perceive text that looks like <<foo>>; if we've disabled Noweb references, then <<foo>> is not given any special treatment. See the discussion for ti:0007-blocks-noweb and ti:0007-blocks-noweb-off at Noweb reference.

Blocks that don't have a name are assigned a __anon_block_number metadata property 119, so that we can use this information to generate a link anchor for it during weaving.

The call to f:firstParentOid 120 gets the first available newtype:OID we can find, going up the headingOidStack in data:OrgParserState.

f:firstParentOid
firstParentOid :: OID -> [Maybe OID] -> OID
firstParentOid docOid maybeOids = case catMaybes maybeOids of
  (oid : _) -> oid
  [] -> docOid

OK, back to BToken values. The tricky part is finding out the end of the block. We must parse the two cases of whether there is or isn't any content inside the block separately, via f:zeroBTokensParser and f:someBTokensParser. We need this distinction because we require blocks to end with "\n#+end_..." (that is, the #+end_... must occur at the beginning of a line). This means that for empty blocks, this newline character just preceding the #+end_... will come from the newline at the end of the #+begin_... part. This then interferes with the char '\n' parser 125 we already have in f:blockStartParser just before we start parsing for bTokens.

In other words, there is no easy way to tell Megaparsec to ensure that we are at the beginning of a line. To work around this, we check whether the block is empty with f:zeroBTokensParser.

f:zeroBTokensParser
zeroBTokensParser :: Text -> OrgParser [BToken]
zeroBTokensParser blockType = do
  _ <- MP.choice [noBlankLine, blankLine]
  pure []
  where
    noBlankLine = string $ T.concat ["#+end_", blockType, "\n"]
    blankLine = string $ T.concat ["\n#+end_", blockType, "\n"]

If zeroBTokensParser fails, then that means there is some content inside the block, and therefore f:someBTokensParser must succeed (hence the use of MP.choice 118 in f:blockParser).

f:someBTokensParser
someBTokensParser :: Text -> Bool -> Bool -> OrgParser [BToken]
someBTokensParser blockType nowebOn lineLabelOn =
  MP.someTill
    (bTokenParser (blockType == "src") nowebOn lineLabelOn)
    ( string
        $ T.concat
          [ "\n#+end_", -- 126
            blockType,
            "\n"
          ]
    )

See ti:0007-blocks-ending which checks the various edge cases around making sure we can detect the "\n#+end_..." bit.

Because we treat the newline that immediately precedes the #+end_... bit as part of the ending marker, we leave it out of the parsed contents. This actually helps us when we have to tangle blocks in f:expandBlock. If we parsed in the trailing newlines as BToken values, these newlines would build up every time we nested into a block (e.g., t:0101-block-expansion-deeply-nested).

f:someBTokensParser relies on f:bTokenParser to do the actual parsing of BToken values, which form the contents of the code block. These BToken values can be either text (BTokenString) or a Noweb link (BTokenLinkTarget). Essentially all of the complexity around f:bTokenParser is due to the need to detect Noweb references in a robust way.

f:bTokenParser
bTokenParser :: Bool -> Bool -> Bool -> OrgParser BToken
bTokenParser isSrcBlock nowebOn lineLabelOn = MP.choice parsers
  where
    baseParsers :: [OrgParser BToken]
    baseParsers =
      -- 127
      [ BTokenString
          <$> ( MP.takeWhile1P
                  (Just "block token")
                  (`notElem` ['\n', ' ', '<'])
              ),
        BTokenString
          . T.singleton
          <$> (MP.label "left angle bracket" $ MP.satisfy (== '<')), -- 128
        BTokenString
          . T.singleton
          <$> (MP.label "space inside block" $ MP.satisfy isSpace)
      ]
    parsers = [p | (b, p) <- extraParsers, b] <> baseParsers
    extraParsers =
      [ ( isSrcBlock && nowebOn,
          BTokenLinkTarget <$> MP.try nowebLinkTargetParser -- 129
        ),
        (lineLabelOn, BTokenLinkTarget <$> lineLabelParser)
      ]

The need to detect Noweb references is why baseParsers 127 has individual (separate) parsers; having these smaller separate parsers breaks up the parsing of block tokens into multiple separate steps. That way, no single parser is too greedy and we can handle the edge cases such as in ti:0007-blocks-noweb-intraline.

Along the same vein, it's important that the space parser (MP.satisfy isSpace) in baseParsers in f:bTokenParser does not use MP.takeWhile1P. Otherwise, it'll greedily consume all spaces including newlines, robbing it from the ending condition (where we expect a newline before #+end_ 126) in f:someBTokensParser. The parser for the left angle bracket 128 is there to take care of an edge case where a Noweb reference is immediately preceded by another left angle bracket <; see t:0007-blocks-noweb-intraline for details.

Going back to f:blockParser, there are three transformations 121 we do for the parsed bTokens --- f:ignoreSpecialCommas, f:compressBTokens, and f:padConsecutiveNowebLinks. Let's look at these in detail.

11.2.1 Ignore special commas

The f:ignoreSpecialCommas function ignores commas at the beginning of a line (with or without indentation), when they are used to escape asterisks (*) and hash signs followed by a plus character (#+). This is because the org-mode major mode in Emacs is unable to cope with these special characters, and requires users to put in leading commas to help the (Elisp-based) parser in that mode. Our Megaparsec-based implementation does not need such help; however we teach our parser about this behavior here so that we can remain compatible with org-mode (otherwise when users tangle their Org file using Lilac, they might get confused why we don't escape such leading commas --- not to mention the likely breakage of Emacs's syntax highlighting for these areas).

f:ignoreSpecialCommas
ignoreSpecialCommas :: [BToken] -> [BToken]
ignoreSpecialCommas = reverse . foldl' f []
  where
    f acc (BTokenString text) = (BTokenString $ removeSpecialCommas text) : acc
    f acc BTokenPadding = BTokenPadding : acc
    f acc link = link : acc
    removeSpecialCommas text =
      T.intercalate "\n"
        . map removeSpecialComma
        $ T.split (== '\n') text
    removeSpecialComma line
      | hasSpecialComma line = dropLeadingComma line
      | otherwise = line
    hasSpecialComma line
      | (indentAndCommas, rest) <- T.breakOnEnd "," line,
        (indent, commas) <- T.breakOn "," indentAndCommas,
        T.stripStart indent == "",
        not $ T.null commas,
        T.all (== ',') commas,
        T.take 1 rest == "*" || T.take 2 rest == "#+" =
          True
      | otherwise = False
    dropLeadingComma line =
      let (indent, commas) = T.breakOn "," line
       in indent <> T.drop 1 commas
ti:0007-blocks-special-commas
🎯 test/Lilac/ParseSpec/0007-blocks-special-commas
#+begin_src sh
Special commas get removed:
,*
,#+
,,*
,,#+
Special commas may have leading whitespace:
 ,*
Non-special commas stay put:
,
,.
, x
#+end_src

If you are reading this (and the above code block) from Emacs Orgmode, realize that we have to do escaping ourselves here, which can be a bit confusing. See test/Lilac/ParseSpec/0007-blocks-special-commas for the raw text to see what it really looks like. The woven HTML version agrees with the raw text, so it should be less tricky to understand.

t:0007-blocks-special-commas
shouldParseWithState
  "0007-blocks-special-commas"
  ( nilOrgDoc
      Expected blocks for 0007-blocks-special-commas
      []
  )
  defaultOrgParserState
    { anonBlockNumber = 1
    }
Expected blocks for 0007-blocks-special-commas
[ CBlock
    Block
      { parentOid = nilOid,
        meta =
          fromList
            [ ("__anon_block_number", "1"),
              ("__block_type", "src"),
              ("__src_language", "sh")
            ],
        body =
          [ BTokenString
              . T.stripEnd
              $ T.unlines
                [ "Special commas get removed:",
                  "*",
                  "#+",
                  ",*",
                  ",#+",
                  "Special commas may have leading whitespace:",
                  " *",
                  "Non-special commas stay put:",
                  ",",
                  ",.",
                  ", x"
                ]
          ]
      }
]

11.2.2 Compress BToken values

The f:compressBTokens function is similar to f:compressProse, but it's simpler because it doesn't have to deal with StyledText. Instead it only deals with raw Text values directly. We need to use compressBTokens because otherwise we'll end up with many separate BToken values (because f:bTokenParser uses multiple separate parsers that all use the same BTokenString constructor for parsing text tokens).

f:compressBTokens
compressBTokens :: [BToken] -> [BToken]
compressBTokens = reverse . foldl' f []
  where
    f :: [BToken] -> BToken -> [BToken]
    f [] token = token : []
    f ((BTokenString text1) : rest) (BTokenString text2) =
      (BTokenString $ T.append text1 text2) : rest
    f acc token = token : acc

11.2.3 Noweb references

To understand f:padConsecutiveNowebLinks 121 in f:blockParser, we have to first look at how we parse Noweb references, aka links. We use the term reference and link somewhat interchangeably here, because the only way to refer to another code block is with a Noweb reference, which automatically becomes a link upon weaving.

A Noweb reference is a reference to another block of code. During tangling, the reference will be replaced with the contents of that other block of code, which we call block expansion. That other block can itself have Noweb references. Cyclic references are (obviously) forbidden, because they will result in infinite expansions. See Cycle detection for Noweb links and also f:expandBlock for how Noweb references are expanded.

To write a Noweb reference, you have to surround it with double-angle-bracket delimiters << and >>. The name itself can be anything other than a newline. Parsing Noweb references is handled by f:nowebLinkTargetParser. We wrap this with MP.try 129 in f:bTokenParser, because we need to backtrack if we fail (after parsing, and consuming, the initial << bit). Backtracking allows the other parsers in baseParsers to parse the remaining text that failed to be parsed as a valid Noweb link.

f:nowebLinkTargetParser
nowebLinkTargetParser :: OrgParser LinkTarget
nowebLinkTargetParser = do
  _ <- string "<<"
  _ <- MP.notFollowedBy $ string "<" -- 130
  (oid, linkName) <- oidReferenceParser "" ">>" -- 131
  _ <- string ">>"
  pure
    $ LinkTargetOid
      LinkOid -- 132
        { oid = oid,
          name = linkName
        }

130 lets us parse <<<ok>>> as < followed by a Noweb link of ok. If we didn't have this, we would treat the initial << as starting the Noweb link and end up with <ok instead. See ti:0007-blocks-noweb-intraline.

The f:oidReferenceParser 131 parses information about the block being referenced, which we encode as a data:LinkOid 132. This is the same parser used for parsing OID references in prose text, as seen in OID references. The only difference is that we use double angle brackets instead of square brackets.

The use of f:oidReferenceParser means that we only need to specify an explicit OID if we're referring to blocks from other Org documents. If we are referring to blocks inside the current document, you can omit the id:... part for simplicity. Indeed, it is preferable to not use an explicit OID for blocks in the current document, because it becomes easier to tell at a glance (when reading the raw Org file) which blocks are from the current document and which ones are coming from elsewhere.

Below we check a typical case with some Noweb references in it. Note that we have to set #+header: :noweb off here because otherwise Orgmode will think <<foo>> and <<bar>> are Noweb references that exist in this doc, and because they don't exist, Lilac will treat it as broken links and abort tangling altogether.

ti:0007-blocks-noweb
🎯 test/Lilac/ParseSpec/0007-blocks-noweb
#+begin_src sh
<<foo>>

<<quux
>>
echo <<bar>> x y z
#+end_src

The <<quux is not interpreted as a Noweb reference, because it doesn't end with >> on the same line.

t:0007-blocks-noweb
shouldParseWithState
  "0007-blocks-noweb"
  ( nilOrgDoc
      Expected blocks for 0007-blocks-noweb
      []
  )
  defaultOrgParserState
    { anonBlockNumber = 1
    }
Expected blocks for 0007-blocks-noweb
[ CBlock
    Block
      { parentOid = nilOid,
        meta =
          fromList
            [ ("__anon_block_number", "1"),
              ("__block_type", "src"),
              ("__src_language", "sh")
            ],
        body =
          [ BTokenLinkTarget
              $ LinkTargetOid
                LinkOid
                  { oid = nilOid,
                    name = "foo"
                  },
            BTokenString "\n\n<<quux\n>>\necho ", -- 133
            BTokenLinkTarget
              $ LinkTargetOid
                LinkOid
                  { oid = nilOid,
                    name = "bar"
                  },
            BTokenString " x y z"
          ]
      }
]

We have to set #+header: :noweb off in Expected blocks for 0007-blocks-noweb as well, to avoid the <<quux\n>> bit 133 from becoming interpreted as a Noweb reference.

The test below checks the nowebOff boolean behavior from f:blockParser. That is, if Lilac sees #+header: :noweb off, it should not treat text that look like <<foo>> as a Noweb link, but as a literal <<foo>> string.

ti:0007-blocks-noweb-off
🎯 test/Lilac/ParseSpec/0007-blocks-noweb-off
#+header: :noweb off
#+begin_src sh
<<foo>>

<<quux
>>
echo <<bar>> x y z
#+end_src
t:0007-blocks-noweb-off
shouldParseWithState
  "0007-blocks-noweb-off"
  ( nilOrgDoc
      Expected blocks for 0007-blocks-noweb-off
      []
  )
  defaultOrgParserState
    { anonBlockNumber = 1
    }
Expected blocks for 0007-blocks-noweb-off
[ CBlock
    Block
      { parentOid = nilOid,
        meta =
          fromList
            [ ("__anon_block_number", "1"),
              ("__block_type", "src"),
              ("__src_language", "sh"),
              ("noweb", "off")
            ],
        body =
          [ BTokenString "<<foo>>\n\n<<quux\n>>\necho <<bar>> x y z"
          ]
      }
]

Because Lilac relies heavily on Noweb references, it will be turned on by default. So the only way to disable them would be to set #+header: :noweb off for each block where you'd want it disabled.

Sometimes we'll have Noweb references surrounded by punctuation. For example, see how we use <<GHC version>> in nix:ghcVersion. This is a typical use case when the referenced block only has 1 line of text in it and we want to inject those contents into an existing line.

ti:0007-blocks-noweb-intraline
🎯 test/Lilac/ParseSpec/0007-blocks-noweb-intraline
#+begin_src sh
(<<foo>>) and "<<bar>>" and <<<ok>>> <<a->b>>
#+end_src
t:0007-blocks-noweb-intraline
shouldParseWithState
  "0007-blocks-noweb-intraline"
  ( nilOrgDoc
      Expected blocks for 0007-blocks-noweb-intraline
      []
  )
  defaultOrgParserState
    { anonBlockNumber = 1
    }
Expected blocks for 0007-blocks-noweb-intraline
[ CBlock
    Block
      { parentOid = nilOid,
        meta =
          fromList
            [ ("__anon_block_number", "1"),
              ("__block_type", "src"),
              ("__src_language", "sh")
            ],
        body =
          [ BTokenString "(",
            BTokenLinkTarget
              $ LinkTargetOid
                LinkOid
                  { oid = nilOid,
                    name = "foo"
                  },
            BTokenString ") and \"",
            BTokenLinkTarget
              $ LinkTargetOid
                LinkOid
                  { oid = nilOid,
                    name = "bar"
                  },
            BTokenString "\" and <",
            BTokenLinkTarget
              $ LinkTargetOid
                LinkOid
                  { oid = nilOid,
                    name = "ok"
                  },
            BTokenString "> ",
            BTokenLinkTarget
              $ LinkTargetOid
                LinkOid
                  { oid = nilOid,
                    name = "a->b"
                  }
          ]
      }
]

11.3 Additional tests

A #+begin_src without an accompanying language should trigger an error.

ti:0007-block-src-without-lang
🎯 test/Lilac/ParseSpec/0007-block-src-without-lang
#+begin_src
#+end_src
t:0007-block-src-without-lang
shouldFailWithMessage
  "0007-block-src-without-lang"
  "block type src requires a language specifier"
  11

Below we test the edge cases around detecting the end of a block.

ti:0007-blocks-ending
🎯 test/Lilac/ParseSpec/0007-blocks-ending
#+name: empty
#+begin_src sh
#+end_src

#+name: end not in beginning of line
#+begin_src sh
 #+end_src
#+end_src

#+name: end has no preceding blank line
#+begin_src sh
foo
#+end_src

#+name: end has preceding blank line
#+begin_src sh
foo

#+end_src

#+name: end has preceding newline (multiple)
#+begin_src sh
foo


#+end_src

#+name: ignore #+end_example because this is a src block
#+begin_src sh
#+end_example
#+end_src
t:0007-blocks-ending
shouldParseWithState
  "0007-blocks-ending"
  ( nilOrgDoc
      Expected blocks for 0007-blocks-ending
      []
  )
  defaultOrgParserState
    { cellNames =
        Set.fromList
          [ "empty",
            "end has no preceding blank line",
            "end has preceding blank line",
            "end has preceding newline (multiple)",
            "end not in beginning of line",
            "ignore #+end_example because this is a src block"
          ]
    }
Expected blocks for 0007-blocks-ending
[ CBlock
    Block
      { parentOid = nilOid,
        meta =
          fromList
            [ ("__block_type", "src"),
              ("__src_language", "sh"),
              ("name", "empty")
            ],
        body = []
      },
  CBlock
    Block
      { parentOid = nilOid,
        meta =
          fromList
            [ ("__block_type", "src"),
              ("__src_language", "sh"),
              ("name", "end not in beginning of line")
            ],
        body = [BTokenString " #+end_src"]
      },
  CBlock
    Block
      { parentOid = nilOid,
        meta =
          fromList
            [ ("__block_type", "src"),
              ("__src_language", "sh"),
              ("name", "end has no preceding blank line")
            ],
        body = [BTokenString "foo"]
      },
  CBlock
    Block
      { parentOid = nilOid,
        meta =
          fromList
            [ ("__block_type", "src"),
              ("__src_language", "sh"),
              ("name", "end has preceding blank line")
            ],
        body = [BTokenString "foo\n"]
      },
  CBlock
    Block
      { parentOid = nilOid,
        meta =
          fromList
            [ ("__block_type", "src"),
              ("__src_language", "sh"),
              ("name", "end has preceding newline (multiple)")
            ],
        body = [BTokenString "foo\n\n"]
      },
  CBlock
    Block
      { parentOid = nilOid,
        meta =
          fromList
            [ ("__block_type", "src"),
              ("__src_language", "sh"),
              ("name", "ignore #+end_example because this is a src block")
            ],
        body = [BTokenString "#+end_example"]
      }
]

12 List items

Lists are similar to headings, because they give you a way to structure your paragraphs.

Lists are made up of list items. A list item is made up of a list head and list body. List heads encode the structure of cells that sit in the list bodies. So list items encode both structure (with list heads) and content (with list bodies). However unlike headings, a list item can have multiple other cells (typically paragraphs) in its body, whereas a heading can only have a single line of prose. Consider the following list:

  1) a

     b

     - c

     d

  2) e

Here the 1) and 2) are list heads for an ordered list. The single dash - before "c" is also a list head, but for an unordered list. The list body for 1) includes the paragraphs a, b, the nested list item c, and the paragraph d. This is another difference versus headings. That is, a list item can have content for it after backing out of a nested list item. To reiterate, in the list shown above the d paragraph is part of the ordered list item 1) even though the nested item c is just above it; this sort of structure is not allowed for headings. For example, in the following situation

* a
** b

anything that comes after ** b will be part of the ** b heading; there is no way to make it belong to * a (any such content must be placed before ** b). List items are not restricted like this, because they always have meaningful indentation (a list head must be preceded by indentation), and this meaning of each indentation level is tracked during the parse.

List bodies may have cells of the following type:

Vanilla Org allows more cell types like code blocks inside list bodies, but Lilac does not. List items should be simple, and things like code blocks just makes list items more complicated. The fact that we support tables and standalone math fragments in list bodies is an accident, not a product of design. Indeed, for typical use cases, you shouldn't need anything more than paragraphs and other list items inside a list.

Below we have a look at some more detailed rules around how lists may be written in an Org file. They are somewhat numerous because of all the edge cases we have to account for.

12.1 Ordered list items

Below are some detailed rules around ordered list items, themselves expressed as ordered list items with links to test cases as examples. The discussions around space characters and indentation only make sense when viewed in the raw Org file, not the woven HTML format.

  1. List items start out with 2-space indentation. This makes them easier to read from the raw Org file. The amount of additional indentation (in order to have nested list items) depends on where the text began in the parent item.

  2. List items must be separated from each other with an empty blank line. This makes them easier to read when reading the Org file in plain text.

  3. Additional hard-wrapped lines for a list item must be aligned with the text of the first line.

  4. List items can have more than one paragraph associated with them.

    This is an example of another paragraph which is still part of same list item, and has 5 spaces of indentation.

  5. List items can be nested.

    1. This is a nested item, underneath "5)". It has 7 spaces of initial indentation (5 needed to line up with the column where the "L" is in "5) List" in the parent item, plus another 2 to indent one more level beyond that). Subsequent lines of this paragraph have 10 spaces of indentation to line up with the text above it.

    See:

  6. Unlike Markdown, the numbers of the list item must increase by one if there are additional items at the same level. And the first number of a level must start from 1.

  7. The format for ordered list items requires a closing parenthesis immediately after the number. So 1) is valid, but 1. is invalid.

  8. Only numbers may be used to denote item position. You cannot use letters or Roman numerals like a) or i).

  9. There is no limit to the number of consecutive list items at the same level (technically, we use an Int type to encode the number, so it will eventually max out, but you'd need billions of items to hit this limit).

  10. The indentation of text changes slightly depending on the number of the item. For example, this line has 6 spaces of indentation, unlike the paragraphs in item "4)" above, which uses 5 spaces.

    1. Similarly, this child item has text which has 11 spaces of indentation, unlike the 10 spaces of indentation in the ordered child item under item "5)" above.

    See:

  11. There can only be up to 2 additional levels of nesting (for a total of 3 levels of items). This is because excessive nesting is an anti-pattern. See the lilac_maxListLevel Lilac extension for customizing this behavior.

  12. Ordered list items may have unordered list items as children, and vice versa. However you may not mix the two types of items at the same level.

  13. A list item may include a table. However the table cannot be on the same line as the list item head. Below is an example of such a table:

    Table 2.
    foobar
    xy
  14. Similarly, a list item may include mathematical equations. However, such an equation must be on a separate line from the list item head, just like for tables (see Rule 13 above).

    \[ a + b = c\]

    The equation above (and this paragraph) are still part of Rule 14.

  15. Lists may be empty. For example, the following child list is valid even though it does not have any content:

    See:

12.2 Unordered list items

Unordered list items use a single dash character -, and always use 2-space indentation (if you are only using unordered list items for nesting). Below is an example:

  - one

  - two

      - foo

      - bar

  - three

Here's an example of mixing ordered and unordered lists:

  - foo

  - bar

      1) one

      2) two

           - baz

           - quux

         Note how this paragraph is under item "2", because it is indented the
         same way as "two", not "quux", above.

      3) three

  - dog

You may mix ordered and unordered lists at the same indentation level, as long as they are not consecutive (you need some intervening cells, such as the "quux" paragraph below):

  - foo

      1) one

      2) two

    quux

      - a

      - b

12.3 Indentation and ListMarker

Parsing list items is tricky because of meaningful indentation (whitespace). Keeping track of the current level of indentation requires additional state during parsing, which we encode with Level (used by listLevels 29 in data:OrgParserState).

data:Level
type Level :: Type
data Level = Level
  { indent :: Int,
    number :: Maybe Int
  }
  deriving stock (Eq, Show)

This keeps track of the indentations and the last seen number (only for ordered lists) of each (nested) level of indentation thus far during parsing. Whenever we see a list head like 1) or -, we record the number used as well as the indentations we expect for the list head and list body. The main point of listLevels is to ensure that the input is correctly formatted, as well as to detect whenever we start nesting an additional level, or if we back out (finish) one or more levels of nesting (when we see dedented text).

As we observe how these listLevels change during parsing, we create "start" and "end" markers for list items at a particular level, which are the values of the data:ListMarker type. The ListMarker objects are parsed as their own cell as a CListItem (see data:Cell).

data:ListMarker
type ListMarker :: Type
data ListMarker
  = LOrderedStart
  | LOrderedEnd
  | LUnorderedStart
  | LUnorderedEnd
  | LItemStart
  | LItemEnd
  deriving stock (Eq, Ord, Generic, Show)
  deriving anyclass (FromJSON, ToJSON)

We take this model from HTML, which uses <ol> and </ol> to encode the start and end of ordered lists (<ul> and </ul> for unordered lists), and <li> and </li> to mark the start and end of a single list item.

Table 3. ListMarker values and their HTML equivalents.
ListMarkerHTML equivalent
LOrderedStart<ol>
LOrderedEnd</ol>
LUnorderedStart<ul>
LUnorderedEnd</ul>
LItemStart<li>
LItemEnd</li>

In summary when we parse a list item, we parse it as a combination of CListItem and other cell types for the list structure and content, respectively. This lets us parse things into a single global list of cells in f:cellParser, without a need to nest them inside a recursive data structure (such as in https://markkarpov.com/tutorial/megaparsec.html#nested-indented-list).

12.4 Parsing initial indentation to close list items

Before getting into parsing the list items and taking into account all of the rules, let's first consider case where we just want to close off the list items, as this is simpler than creating list items. We want to do this every time we parse initial indentation where there is no list head. For example, in the following

  1) a

       - b

     c

the indentation for paragraph c should by itself close off the preceding unordered list item (- b). We can close off existing list items depending on the amount of indentation.

  1. If there is no indentation, we close off all list items.

  2. If there is maximum (already-seen) indentation (to line up with the most-nested list item), we don't close off any list items because we're just continuing the most-nested list item.

  3. Otherwise, we have some amount of indentation and we only want to close off some list levels, depending on how much indentation we detect.

If there is more indentation than the maximum, then we are nesting another level and don't need to close off anything. That's why this situation is not listed above.

Point 1 is captured by zeroIndent in f:listItemsEndParser. Points 2 and 3 are captured by someIndent in the same function.

f:zeroIndent
zeroIndent :: OrgParser (Int, Int)
zeroIndent = do
  pState <- get
  _ <- MP.eof <|> MP.notFollowedBy (char ' ')
  let listLevelsToClose = length pState.listLevels
      continuationIndent = 0 :: Int
  pure (listLevelsToClose, continuationIndent)

In f:zeroIndent we return the number of list levels to close off (in this case all levels that we've detected so far), and the continuation indent. The continuation indent is used by other parsers such as the f:paragraphParser to see how to join up multiple lines into the same cell, assuming those lines have the same continuation indent (the lines continue if it's the same indent, hence the name). For the case of f:zeroIndent, we set the continuation indent to 0 because there is no indentation, and any subsequent lines belonging to the same cell should not have any indentation (it is considered invalid input if we see surprise indentation; see t:0005-paragraph-indentation-mismatch-over).

For f:someIndent, we just try to line up the amount of indentation found with the existing list levels we have and how much indentation they expect.

f:someIndent
someIndent :: OrgParser (Int, Int)
someIndent = do
  pState <- get
  indentFound <- T.length <$> onlySpaces
  let levelsToDropPerIndentationIncrement :: [(Int, Int)]
      levelsToDropPerIndentationIncrement =
        zip [0 ..] -- 137
          . map getContinuationIndentForLevel
          $ pState.listLevels -- 138
      matchingIndent =
        find
          (\(_, continuationIndent) -> indentFound == continuationIndent)
          levelsToDropPerIndentationIncrement
  case matchingIndent of
    Nothing -> fail "indentation does not fit into existing levels"
    Just x@(_levelsToDrop, _continuationIndent) -> pure x

The pState.listLevels 138 is grown by prepending to the list in f:listItemNester. So the most-deeply-nested level will be at the head of the list in pState.listLevels. That's why we use zip [0 ..] 137 --- we want to drop 0 levels if we see indentation matching the most deeply-nested list item.

The f:getContinuationIndentForLevel computes the total indentation for a list level. For example, in

  1) foo

we compute the initial 2 spaces before the 1), the 1) itself (two characters), plus a space character, or a total continuation indent of 5. The dynamic portion here is the number, which will grow as the list grows (e.g., 10) will have a total continuation indent of 6), which is why we use a function here instead of a constant.

f:getContinuationIndentForLevel
getContinuationIndentForLevel :: Level -> Int
getContinuationIndentForLevel level =
  level.indent + maybeNum + punctuation + space
  where
    punctuation :: Int
    punctuation = 1
    space :: Int
    space = 1
    maybeNum :: Int
    maybeNum = case level.number of
      Just n -> length $ (show n :: String)
      Nothing -> 0

Once we determine the amount of levels we need to close off (by parsing f:zeroIndent or f:someIndent), we just go through the list markers we've already seen in listMarkers and close them off via f:getEndingListMarkers. This is what we do in f:listItemsEndParser.

f:listItemsEndParser
listItemsEndParser :: OrgParser Cell
listItemsEndParser = do
  pState <- get
  when (null pState.listMarkers) $ do
    fail "no pending ListMarker objects to close off" -- 139
  when (pState.continuationIndent /= 0) $ do
    fail "not at beginning of line"
  (levelsToDrop, continuationIndent) <- zeroIndent <|> someIndent
  let markers = getEndingListMarkers levelsToDrop pState.listLevels
      markersToDrop = levelsToDrop * 2 -- 140
  put
    pState
      { listLevels = drop levelsToDrop pState.listLevels,
        listMarkers = drop markersToDrop pState.listMarkers,
        continuationIndent = continuationIndent
      }
  pure $ CListItem markers

In f:listItemsEndParser, we initially fail the parser if there are no pending ListMarker objects (no list levels to close off), or if we're not at the beginning of the line (because otherwise we cannot measure indentation accurately), by checking pState.continuationIndent. This check of pState.continuationIndent is required because there is no easy way to check for the just-parsed token (or character) in Megaparsec --- all parsers only see what's in front of it. So then it's up to other parsers to set this to some value greater than 1 (we do this in f:listHeadIndentLevelParser which is part of f:listItemStartParser)

The markersToDrop 140 is always twice the number of levelsToDrop because we always add 2 (LItemStart plus either LOrderedStart or LUnorderedStart) for each level (see f:listItemNester).

If we detect the maximum allowed indentation, then there are no levels to drop, and hence, we'll end up returning CListItem [] which doesn't hold any meaning. Avoiding this is a bit difficult; if we just fail the parser outright, then the amount of parsed indentation will have to be parsed by another downstream parser (this can get messy). So instead we just return CListItem [] and then filter these out in f:orgDocParser. This way we decide once and for all how to deal with leading indentation (and how this closes off existing list items) in f:listItemsEndParser.

The f:getEndingListMarkers function understands how to close off list levels based on whether the level uses an ordered or unordered list item.

f:getEndingListMarkers
getEndingListMarkers :: Int -> [Level] -> [ListMarker]
getEndingListMarkers levelsToDrop = concatMap f . take levelsToDrop
  where
    f :: Level -> [ListMarker]
    f level = case level.number of
      Nothing -> [LItemEnd, LUnorderedEnd]
      Just _ -> [LItemEnd, LOrderedEnd]

12.5 Parsing list heads

In the previous section we considered how to close off list items based purely on indentation. Parsing list heads is similar (because list heads can also close off list items), but more complicated because we also have to take into account how to create list items (list levels). This means we have to deal with all of the possible values of data:ListMarker here.

Parsing the list head (and therefore the data:Level of a list item) is straightforward. For unordered lists we just want to see a dash, returning Nothing 141 because there is no list number.

f:unorderedHeadParser
unorderedHeadParser :: OrgParser (Maybe Int)
unorderedHeadParser = do
  _ <- char '-'
  pure Nothing -- 141

For ordered lists we want to see the number and closing parenthesis.

f:orderedHeadParser
orderedHeadParser :: OrgParser (Maybe Int)
orderedHeadParser = do
  num <- MP.takeWhile1P (Just "ordered list number") isDigit
  _ <- char ')'
  let n = readMaybe (T.unpack num) :: Maybe Int
  when (isNothing n) $ do
    fail $ "could not parse " <> show (T.unpack num) <> " as Int"
  pure n

We check if the number looks like an Int with readMaybe.

For both cases we also parse leading indentation and also an additional space at the end to separate the list head from the body. This is what f:listHeadIndentLevelParser does.

f:listHeadIndentLevelParser
listHeadIndentLevelParser :: OrgParser Level
listHeadIndentLevelParser = do
  minimalIndentation -- 142
  spaces <- onlySpaces
  listHead <- unorderedHeadParser <|> orderedHeadParser
  let level =
        Level
          { indent = T.length spaces,
            number = listHead
          }
  afterListHead level
  pure level
  where
    minimalIndentation :: OrgParser ()
    minimalIndentation =
      void
        . MP.try
        . MP.lookAhead
        . MP.label "minimal indentation"
        $ MP.count 2 (char ' ')
    afterListHead :: Level -> OrgParser () -- 143
    afterListHead level =
      void
        $ MP.choice
          [ MP.eof,
            void $ string "\n",
            void $ seeingContentAfterListHead level
          ]
    seeingContentAfterListHead :: Level -> OrgParser ()
    seeingContentAfterListHead level = do
      _ <- char ' '
      MP.notFollowedBy (char ' ')
      modify
        (\s -> s {continuationIndent = getContinuationIndentForLevel level})

The minimalIndentation 142 expects there to be at least 2 spaces preceding a list item. This follows Rule 1 from Ordered list items.

The afterListHead parser 143 preemptively checks what's in the input after parsing the list head. In the case where it sees some other non-empty content, it expects some other parser to continue parsing. So inside seeingContentAfterListHead we set the continuationIndent attribute to indentation of the current level, because we expect another parser to help us finish parsing the line. Then at the f:cellParser level, we set this back to 0 for any parser that is expected to consume content and end with a newline. This lets f:listItemsEndParser know whether it is looking at EOF or at the beginning of a line when it inspects continuationIndent. If Megaparsec can let us easily check the previous character that was parsed, this scheme of using a stateful continuationIndent field wouldn't be necessary.

Note, however, that f:listHeadIndentLevelParser only checks for textual correctness. It doesn't check whether what's parsed is semantically correct. For example, we wouldn't want to blindly parse the number of spaces in the indent (instead we would want to check which indents are expected, given any previous list items). Similarly we would want to check whether the list head number (for ordered lists) is the expected (incremented) number from the previously-seen list head number, if any (or if there is no previous number, that this number is 1).

We have to perform these additional checks by comparing the output of f:listHeadIndentLevelParser against the existing data:OrgParserState (listLevels). This is what we do in f:listItemStartParser below, which wraps around f:listHeadIndentLevelParser 145.

f:listItemStartParser
listItemStartParser :: OrgParser Cell
listItemStartParser = do
  _ <- MP.try $ MP.lookAhead listHeadIndentLevelParser -- 144
  pState <- get
  let levels = pState.listLevels
  level <- listHeadIndentLevelParser -- 145
  listMarkers <-
    if nestsBeyond levels level
      then listItemNester level levels
      else listItemFitter level levels
  pure $ CListItem listMarkers
  where
    nestsBeyond :: [Level] -> Level -> Bool -- 146
    nestsBeyond [] _ = True
    nestsBeyond (deepestLevel : _) level =
      getContinuationIndentForLevel deepestLevel + 2 == level.indent

The use of MP.lookAhead 144 is needed because otherwise as soon as Megaparsec sees any indentation (as part of invoking f:listHeadIndentLevelParser) it will expect to see a list head; obviously this is not always the case as not all indentation leads to a list head. This is why we have to perform this look-ahead preemptively.

The nestsBeyond function 146 just checks whether the indentation we found in the (just-parsed) level is the same as the continuation indent of the most-deeply-nested level we have so far, plus 2 extra spaces. This is how we nest one level deeper than the current level. If this is the case, we delegate to f:listItemNester. Otherwise the list item is merely continuing some existing list, for which we delegate to f:listItemFitter.

The use of 2 extra spaces is necessary because a list item must always be indented 2 spaces relative to the just-preceding paragraph.

12.6 Nesting the list item beyond the current level

f:listItemNester prepends starting markers to listMarkers. We use this information in other places when we want to know how to un-nest an item (close it off), such as in f:listItemsEndParser where we add corresponding closing markers with f:getEndingListMarkers. The other place we use listMarkers is in f:listItemFitter.

f:listItemNester
listItemNester :: Level -> [Level] -> OrgParser [ListMarker]
listItemNester level levels = do
  case level.number of
    Just 1 -> pure ()
    Just n -> fail $ "starting list head number must be 1; got " <> show n
    Nothing -> pure ()
  pState <- get
  let newMarker = case level.number of
        Just _ -> LOrderedStart
        Nothing -> LUnorderedStart
      opc = pState.orgParserConf
  when (opc.maxListLevel > 0 && length levels == opc.maxListLevel) $ do
    fail
      $ "max nesting depth exceeded: cannot have more than "
      <> show opc.maxListLevel
      <> " levels of list items"
  put
    pState -- 147
      { listLevels = level : levels,
        listMarkers = LItemStart : newMarker : pState.listMarkers
      }
  pure [newMarker, LItemStart]

We enforce some additional rules here, such as forcing the nesting list head to start off with 1 (see t:0009-ordered-lists-start-from-1) if it is an ordered list. The rest of the function just updates the pState to have the additional level and also grows the listMarkers accordingly 147.

12.7 Fitting the list item into an existing level

The goal of f:listItemFitter is twofold:

  1. continue an existing list, and

  2. close off (finish) pending levels which are still open.

The first task is easy --- we just append [LItemEnd, LItemStart] to signal that we are closing off the existing list item (at the same level) to start a new list item (also at the same level).

The second task is a bit more involved. We have to "close" any existing levels that are more deeply nested, just like in f:listItemsEndParser. For example, in the following list

  1) foo

     - bar

       1) baz

  2) quux

when we hit quux we have to close off the list environments for baz, then bar. However, unlike f:listItemsEndParser, we have to continue the existing list level that we fit into. For ordered list items, we have to drop an extra level in listLevels in the fitter function 148, and then replace it with the (incremented) level to continue it.

f:listItemFitter
listItemFitter :: Level -> [Level] -> OrgParser [ListMarker]
listItemFitter level levels =
  case find (\(_, l) -> l.indent == level.indent) (zip [0 ..] levels) of
    Nothing -> fail "list item: invalid indentation"
    Just (levelsToDrop, lvl)
      | isJust level.number && isJust lvl.number ->
          let expectedNum = fmap (+ 1) lvl.number
           in if level.number == expectedNum
                then fitter levelsToDrop
                else
                  fail
                    $ "expected incremented list head "
                    <> (show . fromJust $ expectedNum)
                    <> " but got "
                    <> (show . fromJust $ level.number)
      | isNothing level.number && isNothing lvl.number -> fitter levelsToDrop
      | isJust lvl.number -> fail "expected ORDERED list item at this level"
      | otherwise -> fail "expected UNORDERED list item at this level"
  where
    fitter :: Int -> OrgParser [ListMarker]
    fitter levelsToDrop = do
      pState <- get
      let markers = getEndingListMarkers levelsToDrop pState.listLevels
      put
        pState
          { listLevels =
              level : drop (levelsToDrop + 1) pState.listLevels, -- 148
            listMarkers = drop (levelsToDrop * 2) pState.listMarkers
          }
      pure $ markers <> [LItemEnd, LItemStart]

We use f:listItemStartParser and f:listItemsEndParser inside f:cellParser; the former identifies the beginnings of list items, while the latter recognizes their (complete) end when there is no more indentation.

12.8 Tests

There are so many rules associated with ordered lists; we try to check a few of them here.

ti:0009-list-indentation-no-indent
🎯 test/Lilac/ParseSpec/0009-list-indentation-no-indent
1)

2)
t:0009-list-indentation-no-indent
shouldParseWith
  "0009-list-indentation-no-indent"
  $ nilOrgDoc
    [ plainParagraph "1)",
      plainParagraph "2)"
    ]
    []

The list-like text is parsed as plain paragraphs in t:0009-list-indentation-no-indent, because the lack of indentation means we don't recognize the 1) and 2) as list items.

Here's a list with the (proper) 2-space indent.

ti:0009-list-indentation-proper-indent
🎯 test/Lilac/ParseSpec/0009-list-indentation-proper-indent
  1)

  2)
t:0009-list-indentation-proper-indent
shouldParseWith
  "0009-list-indentation-proper-indent"
  $ nilOrgDoc
    [ CListItem
        [ LOrderedStart,
          LItemStart
        ],
      CListItem
        [ LItemEnd,
          LItemStart
        ],
      CListItem
        [ LItemEnd,
          LOrderedEnd
        ]
    ]
    []

Ordered lists require a closing parenthesis ) after the number.

ti:0009-ordered-list-head-needs-parenthesis
🎯 test/Lilac/ParseSpec/0009-ordered-list-head-needs-parenthesis
  1.

  2.
t:0009-ordered-list-head-needs-parenthesis
shouldFailWithError
  "0009-ordered-list-head-needs-parenthesis"
  (utoks "." <> etoks ")" <> elabel "ordered list number")
  3

Ordered lists may only use numbers, not letters or Roman numerals.

ti:0009-ordered-list-head-cannot-use-letters
🎯 test/Lilac/ParseSpec/0009-ordered-list-head-cannot-use-letters
  a)
t:0009-ordered-list-head-cannot-use-letters
shouldFailWithMessage
  "0009-ordered-list-head-cannot-use-letters"
  "invalid indentation"
  2

Here's a nested example.

ti:0009-list-indentation-nested
🎯 test/Lilac/ParseSpec/0009-list-indentation-nested
  1)

       -

       -

  2)

       1)

            -

  3)
t:0009-list-indentation-nested
shouldParseWith
  "0009-list-indentation-nested"
  $ nilOrgDoc
    [ CListItem
        [ LOrderedStart, ---- Start outer ordered list.
          LItemStart -------- Start outer "1)".
        ],
      CListItem
        [ LUnorderedStart, -- Start inner bullets under "1)".
          LItemStart -------- Inner bullet start.
        ],
      CListItem
        [ LItemEnd, --------- Inner bullet end.
          LItemStart -------- Inner bullet start.
        ],
      CListItem
        [ LItemEnd, --------- Inner bullet end.
          LUnorderedEnd, ---- End of inner bullets under "1)".
          LItemEnd, --------- End of outer "1)".
          LItemStart -------- Start of outer "2)".
        ],
      CListItem
        [ LOrderedStart, ---- Start of nested ordered list.
          LItemStart -------- Start of nested "1)".
        ],
      CListItem
        [ LUnorderedStart, -- Start deeply nested bullets.
          LItemStart -------- Deeply nested bullet start.
        ],
      CListItem
        [ LItemEnd, --------- Deeply nested bullet end.
          LUnorderedEnd, ---- End of deeply nested bullets.
          LItemEnd, --------- End of nested "1)".
          LOrderedEnd, ------ End of nested ordered list.
          LItemEnd, --------- End of outer "2)".
          LItemStart -------- Start of outer "3)".
        ],
      CListItem
        [ LItemEnd, --------- End of outer "3)".
          LOrderedEnd ------- End outer ordered list.
        ]
    ]
    []

Nesting is limited to 2 additional levels, for a total of 3 levels by default.

ti:0009-list-nesting-max
🎯 test/Lilac/ParseSpec/0009-list-nesting-max
  - 1

      - 2

          - 3

              - 4
t:0009-list-nesting-max
shouldFailWithMessage
  "0009-list-nesting-max"
  "max nesting depth exceeded: cannot have more than 3 levels of list items"
  49

You can change the maximum nesting behavior with lilac_maxListLevel Lilac extension. In the test below we increase the total list level to 9.

ti:0009-list-nesting-max-8
🎯 test/Lilac/ParseSpec/0009-list-nesting-max-8
#+lilac_maxListLevel: 8
  - 1

      - 2

          - 3

              - 4

                  - 5

                      - 6

                          - 7

                              - 8

                                  - 9
t:0009-list-nesting-max-8
shouldFailWithMessage
  "0009-list-nesting-max-8"
  "max nesting depth exceeded: cannot have more than 8 levels of list items"
  228

Notice how the parser failed only after parsing the last level which exceeded the 8 levels allowed.

We can prohibit list items entirely, much like how we can prohibit them in t:0004-headings-prohibited.

ti:0009-lists-prohibited
🎯 test/Lilac/ParseSpec/0009-lists-prohibited
#+lilac_maxListLevel: -1
  - 1
t:0009-lists-prohibited
shouldFailWithMessage
  "0009-list-prohibited"
  "lists prohibited because lilac_maxListLevel < 0"
  29

Now we test whether list items can also hold content (other cells) inside it, in this case paragraph cells.

ti:0009-list-content-simple
🎯 test/Lilac/ParseSpec/0009-list-content-simple
  1) First
     paragraph.

     Second paragraph.

  2) Another paragraph.
t:0009-list-content-simple
shouldParseWith
  "0009-list-content-simple"
  $ nilOrgDoc
    [ CListItem
        [ LOrderedStart,
          LItemStart
        ],
      plainParagraph "First paragraph.",
      plainParagraph "Second paragraph.",
      CListItem
        [ LItemEnd,
          LItemStart
        ],
      plainParagraph "Another paragraph.",
      CListItem
        [ LItemEnd,
          LOrderedEnd
        ]
    ]
    []

Here's a more involved example using paragraph text for list items, following the pattern from ti:0009-list-indentation-nested. Note how the parsed list structure is still there, except now we have CParagraph cells interspersed in between them.

ti:0009-list-indentation-nested-with-text
🎯 test/Lilac/ParseSpec/0009-list-indentation-nested-with-text
Foo.

  1) one

       - a

       - b

  2) two

       1) two-point-one

          hello

            - X

  3) three

Bar.
t:0009-list-indentation-nested-with-text
shouldParseWith
  "0009-list-indentation-nested-with-text"
  $ nilOrgDoc
    [ plainParagraph "Foo.",
      CListItem
        [ LOrderedStart, ---- Start outer ordered list.
          LItemStart -------- Start outer "1)".
        ],
      plainParagraph "one",
      CListItem
        [ LUnorderedStart, -- Start inner bullets under "1)".
          LItemStart -------- Inner bullet start.
        ],
      plainParagraph "a",
      CListItem
        [ LItemEnd, --------- Inner bullet end.
          LItemStart -------- Inner bullet start.
        ],
      plainParagraph "b",
      CListItem
        [ LItemEnd, --------- Inner bullet end.
          LUnorderedEnd, ---- End of inner bullets under "1)".
          LItemEnd, --------- End of outer "1)".
          LItemStart -------- Start of outer "2)".
        ],
      plainParagraph "two",
      CListItem
        [ LOrderedStart, ---- Start of nested ordered list.
          LItemStart -------- Start of nested "1)".
        ],
      plainParagraph "two-point-one",
      plainParagraph "hello",
      CListItem
        [ LUnorderedStart, -- Start deeply nested bullets.
          LItemStart -------- Deeply nested bullet start.
        ],
      plainParagraph "X",
      CListItem
        [ LItemEnd, --------- Deeply nested bullet end.
          LUnorderedEnd, ---- End of deeply nested bullets.
          LItemEnd, --------- End of nested "1)".
          LOrderedEnd, ------ End of nested ordered list.
          LItemEnd, --------- End of outer "2)".
          LItemStart -------- Start of outer "3)".
        ],
      plainParagraph "three",
      CListItem
        [ LItemEnd, --------- End of outer "3)".
          LOrderedEnd ------- End outer ordered list.
        ],
      plainParagraph "Bar."
    ]
    []

Check whether we can detect invalid indentations for paragraphs that are indented (indented due to them being inside a list). The first case is when the line is indented too much in the list body.

ti:0005-paragraph-indentation-mismatch-over-list-body
🎯 test/Lilac/ParseSpec/0005-paragraph-indentation-mismatch-over-list-body
  1) Foo
       bar
     baz.
t:0005-paragraph-indentation-mismatch-over-list-body
shouldFailWithError
  "0005-paragraph-indentation-mismatch-over-list-body"
  (utoks " ")
  14

The same rule about keeping the same indentation applies for plain paragraphs outside of list environments.

ti:0005-paragraph-indentation-mismatch-over
🎯 test/Lilac/ParseSpec/0005-paragraph-indentation-mismatch-over
Foo
  bar
baz.
t:0005-paragraph-indentation-mismatch-over
shouldFailWithError
  "0005-paragraph-indentation-mismatch-over"
  (utoks " ")
  4

Under-indentation is also a problem for continuation lines inside a single paragraph block.

ti:0005-paragraph-indentation-mismatch-under-list-body
🎯 test/Lilac/ParseSpec/0005-paragraph-indentation-mismatch-under-list-body
  1) Foo
   bar
     baz.
t:0005-paragraph-indentation-mismatch-under-list-body
shouldFailWithError
  "0005-paragraph-indentation-mismatch-under-list-body"
  (utoks "b" <> etoks " ")
  12

If we have some number of list levels already, then the paragraph must fit into one of those levels (it cannot have some arbitrary level of indentation) if there is non-zero indentation.

ti:0005-paragraph-indentation-mismatch-initial-indent-invalid
🎯 test/Lilac/ParseSpec/0005-paragraph-indentation-mismatch-initial-indent-invalid
  1) Foo
     bar.

 Invalid indentation (this paragraph).
t:0005-paragraph-indentation-mismatch-initial-indent-invalid
shouldFailWithMessage
  "0005-paragraph-indentation-mismatch-initial-indent-invalid"
  "indentation does not fit into existing levels"
  21
ti:0005-paragraph-indentation-mismatch-initial-indent-invalid-between-levels
🎯 test/Lilac/ParseSpec/0005-paragraph-indentation-mismatch-initial-indent-invalid-between-levels
  1) Foo
     bar.

       - Baz.

   Invalid indentation (this paragraph).
t:0005-paragraph-indentation-mismatch-initial-indent-invalid-between-levels
shouldFailWithMessage
  "0005-paragraph-indentation-mismatch-initial-indent-invalid-between-levels"
  "indentation does not fit into existing levels"
  38

Paragraphs should be able to close list environments, depending on their indentation. For example, in the test below the paragraph Three should be able to close off the unordered list items.

ti:0009-paragraph-can-close-deeper-levels
🎯 test/Lilac/ParseSpec/0009-paragraph-can-close-deeper-levels
  1) One

     Two
     x

       - Bar

           - Baz

     Three
     a b
     c

Four
t:0009-paragraph-can-close-deeper-levels
shouldParseWith
  "0009-paragraph-can-close-deeper-levels"
  $ nilOrgDoc
    [ CListItem
        [ LOrderedStart,
          LItemStart
        ],
      plainParagraph "One",
      plainParagraph "Two x",
      CListItem
        [ LUnorderedStart,
          LItemStart
        ],
      plainParagraph "Bar",
      CListItem
        [ LUnorderedStart,
          LItemStart
        ],
      plainParagraph "Baz",
      CListItem
        [ LItemEnd,
          LUnorderedEnd,
          LItemEnd,
          LUnorderedEnd
        ],
      plainParagraph "Three a b c",
      CListItem
        [ LItemEnd,
          LOrderedEnd
        ],
      plainParagraph "Four"
    ]
    []

Paragraphs can close off list items, so we can mix ordered and unordered list items at the same level, as long as the differing list type is closed off by a paragraph. In the test below, the Two closes off the unordered list, so we're allowed to start a new ordered list with 1) a.

ti:0009-paragraph-can-separate-mixed-lists
🎯 test/Lilac/ParseSpec/0009-paragraph-can-separate-mixed-lists
  1) One

       - Bar

           - Baz

     Two

       1) a

       2) b

     Three

Four
t:0009-paragraph-can-separate-mixed-lists
shouldParseWith
  "0009-paragraph-can-separate-mixed-lists"
  $ nilOrgDoc
    [ CListItem
        [ LOrderedStart,
          LItemStart
        ],
      plainParagraph "One",
      CListItem
        [ LUnorderedStart,
          LItemStart
        ],
      plainParagraph "Bar",
      CListItem
        [ LUnorderedStart,
          LItemStart
        ],
      plainParagraph "Baz",
      CListItem
        [ LItemEnd,
          LUnorderedEnd,
          LItemEnd,
          LUnorderedEnd
        ],
      plainParagraph "Two",
      CListItem
        [ LOrderedStart,
          LItemStart
        ],
      plainParagraph "a",
      CListItem
        [ LItemEnd,
          LItemStart
        ],
      plainParagraph "b",
      CListItem
        [ LItemEnd,
          LOrderedEnd
        ],
      plainParagraph "Three",
      CListItem
        [ LItemEnd,
          LOrderedEnd
        ],
      plainParagraph "Four"
    ]
    []

It's an error if there is no separation between list types (if we suddenly try to change list types at the same level). In the test below, the 1) c is unexpected because the unordered list has not been closed off yet.

ti:0009-lists-cannot-be-mixed-on-their-own
🎯 test/Lilac/ParseSpec/0009-lists-cannot-be-mixed-on-their-own
  - a

      - b

  1) c
t:0009-lists-cannot-be-mixed-on-their-own
shouldFailWithMessage
  "0009-lists-cannot-be-mixed-on-their-own"
  "expected UNORDERED list item at this level"
  23

The same applies for going the other way (trying to fit an unordered list into an ordered one at the same level).

ti:0009-lists-cannot-be-mixed-on-their-own-ordered
🎯 test/Lilac/ParseSpec/0009-lists-cannot-be-mixed-on-their-own-ordered
  1) a

       1) b

  - c
t:0009-lists-cannot-be-mixed-on-their-own-ordered
shouldFailWithMessage
  "0009-lists-cannot-be-mixed-on-their-own-ordered"
  "expected ORDERED list item at this level"
  25

List items have to be precisely indented; otherwise they'll hit an indentation error. In the test below, the 1) for the b item has only 4 spaces of indentation instead of 5 (to line up with the a above it), so that should trigger an indentation error.

ti:0009-lists-require-precise-indentation
🎯 test/Lilac/ParseSpec/0009-lists-require-precise-indentation
  1) a

    1) b
t:0009-lists-require-precise-indentation
shouldFailWithMessage
  "0009-lists-require-precise-indentation"
  "list item: invalid indentation"
  15

List items must be separated by at least one blank line. In the test case below, there is not enough space so the 1) b gets interpreted as regular paragraph text, as part of the a above it.

ti:0009-list-items-need-blank-line-separator
🎯 test/Lilac/ParseSpec/0009-list-items-need-blank-line-separator
  1) a
     1) b
t:0009-list-items-need-blank-line-separator
shouldParseWith
  "0009-list-items-need-blank-line-separator"
  $ nilOrgDoc
    [ CListItem
        [ LOrderedStart,
          LItemStart
        ],
      plainParagraph "a 1) b",
      CListItem
        [ LItemEnd,
          LOrderedEnd
        ]
    ]
    []

Ordered list items at the same level must increment by 1 each time. In the test case below, the 1) b will fail to parse because it should be 2) b instead.

ti:0009-ordered-lists-require-increment
🎯 test/Lilac/ParseSpec/0009-ordered-lists-require-increment
  1) a

  1) b
t:0009-ordered-lists-require-increment
shouldFailWithMessage
  "0009-ordered-lists-require-increment"
  "expected incremented list head 2 but got 1"
  13

Ordered list items must start from 1.

ti:0009-ordered-lists-start-from-1
🎯 test/Lilac/ParseSpec/0009-ordered-lists-start-from-1
  1) a

       20) b
t:0009-ordered-lists-start-from-1
shouldFailWithMessage
  "0009-ordered-lists-start-from-1"
  "starting list head number must be 1; got 20"
  19

13 Description lists, aka glossary terms

Description lists are unordered list items that have a term followed by two colons (::), and a prose description of that term. Structurally they are quite similar to footnote definitions, which also have a leading name followed by the body (see data:Footnote).

The term "description list" comes from HTML, and it would appear that Orgmode borrowed the idea quite directly. For example, vanilla Orgmode will export the following Org syntax

  - foo /hello/ :: bar

  - baz :: quux

to HTML as

<dl class="org-dl">
<dt>foo <i>hello</i></dt><dd>bar</dd>

<dt>baz</dt><dd>quux</dd>
</dl>

In HTML it is perfectly valid to have multiple <dt> (term) elements, or multiple <dd> (definition) elements, or both.

There are three differences worth noting for Lilac:

  1. Only one term and one description is allowed. If multiple terms mean the same thing, there should be multiple entries and all but one should redirect the user to look up the canonical entry that has the full definition. Multiple definitions for a single term are not allowed. Instead, you should use terms like foo (1) :: ... and foo (2) :: ... to define two different definitions for the same term.

  2. We don't call these description lists because that language is too close to the implementation detail. Instead we call them "glossary terms" (or simply "terms") because that's what they are used for most of the time.

  3. Lilac treats each key-value pair as a single data:Cell, so you can write them anywhere a cell is expected. So whereas Orgmode only allows glossary terms them inside unordered lists, in Lilac you can write them as their own independent paragraphs, or in ordered lists, etc.

    This way Lilac can mix and match glossary terms with other items (it does not need the Orgmode workarounds described here). See how glossary terms are woven to see how this plays out during HTML export.

With those things out of the way, let's look at data:GlossaryTerm, our take on definition list entries:

data:GlossaryTerm
type GlossaryTerm :: Type
data GlossaryTerm = GlossaryTerm
  { term :: Prose,
    definition :: Prose
  }
  deriving stock (Eq, Ord, Generic, Show)
  deriving anyclass (FromJSON, ToJSON)

Glossary terms are created from newtype:Prose in f:convertToGlossaryTerm. A paragraph is checked if it looks like a glossary term entry in f:paragraphParser, and delegates to f:convertToGlossaryTerm if so.

f:convertToGlossaryTerm
convertToGlossaryTerm :: Prose -> Int -> OrgParser Cell
convertToGlossaryTerm prose offset =
  toGlossaryTerm
    $ foldl' f ([], Prose [], []) prose.unProse
  where
    f (termPTokens, term'@(Prose finalTermPTokens), def) pToken
      | finalTermPTokens == [] =
          if isGlossaryTermDelimiter pToken -- 149
            then
              ( termPTokens,
                Prose . reverse $ dropWhitespacePToken termPTokens,
                def
              )
            else (pToken : termPTokens, term', def)
      | otherwise = (termPTokens, term', pToken : def)
    dropWhitespacePToken :: [PToken] -> [PToken] -- 150
    dropWhitespacePToken [] = []
    dropWhitespacePToken tokens@(pToken : rest)
      | (PTokenStyledText styledText) <- pToken,
        Just (_, ' ') <- T.unsnoc styledText.body =
          rest
      | otherwise = tokens
    toGlossaryTerm :: ([PToken], Prose, [PToken]) -> OrgParser Cell
    toGlossaryTerm (_, term', def) = do
      let proseDefinition = compressProse . Prose $ reverse def
      assertUniqueGlossaryTerm (toText term') offset -- 151
      pure
        $ CGlossaryTerm
          GlossaryTerm
            { term = term',
              definition = proseDefinition
            }

150 removes any whitespace token just before the :: delimiter. This is needed because otherwise the term string will end with trailing whitespace.

f:convertToGlossaryTerm just checks each PToken in prose if it looks like a glossary term delimiter via f:isGlossaryTermDelimiter 149. We have to check each PToken because we cannot tell what the term looks like; for example it could be that the term is 15 words long before we finally see the :: delimiter.

f:isGlossaryTermDelimiter
isGlossaryTermDelimiter :: PToken -> Bool
isGlossaryTermDelimiter = \case
  PTokenStyledText styledText ->
    styledText.body == "::" && maskToStyles styledText.style == []
  _ -> False

The important bit here is the check of whether no styles are on when encountering the string ::. This way, we don't interpret strings like

Foo ~a::b~ bar.

as a glossary term because the :: will be inside the code (~) delimiters.

We require all glossary terms to be unique within the document in f:assertUniqueGlossaryTerm 151.

f:assertUniqueGlossaryTerm
assertUniqueGlossaryTerm :: Text -> Int -> OrgParser ()
assertUniqueGlossaryTerm term offset = do
  pState <- get
  when (member term pState.glossary) $ do
    MP.setOffset offset -- 152
    fail -- 153
      $ "glossary term "
      <> show term
      <> " is not unique"
  put
    pState
      { OrgParserState.glossary =
          insert
            term
            pState.cellNumber
            pState.glossary
      }
ti:0015-glossary-duplicate-term
🎯 test/Lilac/ParseSpec/0015-glossary-duplicate-term
foo :: 1

foo :: 2
t:0015-glossary-duplicate-term
shouldFailWithMessage
  "0015-glossary-duplicate-term"
  "glossary term \"foo\" is not unique"
  14

Originally we naively set the 152 to be the beginning of the paragraph (as seen by f:paragraphParser). However, this had the side effect of making Megaparsec think that we had not consumed any input when failing at 153. So then Megaparsec would also report other parser errors for those parsers that also did not consume any input and still signaled a failure in f:cellParser, namely in f:listItemsEndParser (139). In order to suppress that error, we changed f:textParser to set a glossaryTermOffset if it detected the glossary term delimiter (71)3.

13.1 Printing glossaries

Similar to Printing bibliographies, we would like to allow users to print a collection of all glossary terms found in the document (with links back to where the term was originally defined --- for added context). We use the line #+print_glossary: local for this. The local just means printing the glossary terms found in the current document. You can change this to be global to print all glossary terms found in the entire data:Archive (e.g., see Glossary (Global)).

f:glossaryLocationParser
glossaryLocationParser :: OrgParser Cell
glossaryLocationParser = do
  _ <- string "#+print_glossary: "
  isGlobal <-
    MP.choice
      [ string "local" >> pure False,
        string "global" >> pure True
      ]
  pure . CCommand $ PrintGlossary isGlobal
ti:0015-glossary-location
🎯 test/Lilac/ParseSpec/0015-glossary-location
#+print_glossary: local
t:0015-glossary-location
shouldParseWithState
  "0015-glossary-location"
  OrgDoc
    { oid = nilOid,
      cells = [CCommand $ PrintGlossary False],
      pseudoEdges = [],
      footnoteRefs = M.empty,
      glossary = M.empty,
      lineLabels = M.empty,
      cslPath = Nothing,
      bibPaths = [],
      citations = [],
      meta =
        fromList
          [ ("ID", "00000000-0000-0000-0000-000000000000")
          ]
    }
  defaultOrgParserState
ti:0015-glossary-location-global
🎯 test/Lilac/ParseSpec/0015-glossary-location-global
#+print_glossary: global
t:0015-glossary-location-global
shouldParseWithState
  "0015-glossary-location-global"
  OrgDoc
    { oid = nilOid,
      cells = [CCommand $ PrintGlossary True],
      pseudoEdges = [],
      footnoteRefs = M.empty,
      glossary = M.empty,
      lineLabels = M.empty,
      cslPath = Nothing,
      bibPaths = [],
      citations = [],
      meta =
        fromList
          [ ("ID", "00000000-0000-0000-0000-000000000000")
          ]
    }
  defaultOrgParserState

14 Figures

A Figure is just a link to an image file, preceded by some metadata.

data:Figure
type Figure :: Type
data Figure = Figure
  { parentOid :: OID, -- 154
    meta :: Map Text Text, -- 155
    caption :: Prose, -- 156
    link :: Link -- 157
  }
  deriving stock (Eq, Ord, Generic, Show)
  deriving anyclass (FromJSON, ToJSON)

The parentOid 154 is just like the one in data:Block.

The metadata (meta 155) recognizes certain reserved keys:

The caption (taken from the metadata) is saved into the caption field 156. Finally the link 157 is the link to the image which will be displayed in the woven HTML.

Below is an example of a figure:

Figure 1. Portable Network Graphics sample image.
[[https://en.wikipedia.org/wiki/PNG][Portable Network Graphics]] sample image.

Below is another example, but the figure does not have a name in the metadata, nor a caption. This is still valid.

Figure 2.

The only practical difference is that the unnamed figure is not able to be referenced using a data:LinkOid by this or other Org files.

f:figureParser is responsible for parsing figures.

f:figureParser
figureParser :: OrgParser Cell
figureParser = do
  pState <- get
  when (pState.listLevels /= [])
    $ fail "figures are not allowed inside list items"
  _ <-
    MP.try
      $ MP.lookAhead
        ( metaParser
            >> fileLinkParser
            >> endOfCell -- 158
        )
  metaKVs <- metaParser -- 159
  when (lookup "type" metaKVs /= Just "figure")
    $ fail "type must be 'figure'"
  caption <- case lookup "caption" metaKVs of -- 160
    Nothing -> pure $ Prose []
    Just s -> case MP.runParser (runStateT captionParser pState) "" s of
      Left err -> fail $ MP.errorBundlePretty err
      Right (prose, _pState) -> pure prose
  link <- fileLinkParser -- 161
  let newFigureNumber = pState.figureNumber + 1
      figureNumberMeta = ("__figure_number", T.show newFigureNumber)
  case link.target of
    LinkTargetOid {}
    LinkTargetLineLabel {}
    LinkTargetURI {}
    LinkTargetFootnote {} ->
        fail "'file:...' required for link to figures" -- 162
    LinkTargetFilePath _ -> do
      modify (\s -> s {figureNumber = newFigureNumber})
      pure
        . CFigure
        $ Figure
          { parentOid =
              firstParentOid pState.docOid pState.headingOidStack,
            meta =
              foldl'
                (\acc (k, v) -> insert k v acc)
                (delete "type" metaKVs)
                [figureNumberMeta],
            caption = caption,
            link = link
          }
  where
    endOfCell :: OrgParser () -- 163
    endOfCell =
      MP.choice
        [ void . MP.try $ string "\n\n",
          char '\n' >> MP.eof
        ]

First we have to parse for the metadata 159, then the optional caption 160, and then the link to the image 161. For simplicity, we don't allow figures inside list items. For the link target itself, we check that it's pointing to a file (instead of accepting just any link) 162.

It's important that we expect the end of the cell 158 163, because otherwise we'll end up parsing the link at the start of a paragraph cell as a figure.

14.1 Tests

Below we parse a doc that has figures and blocks, both with metadata.

ti:0011-figures-and-blocks
🎯 test/Lilac/ParseSpec/0011-figures-and-blocks
#+name: f:f
#+header: :xxx bar
#+begin_src haskell
f = 1 + 1
#+end_src

#+name: fig:hello
#+caption: =hello=
#+type: figure
#+width: 100%
[[file:image/hello.png]]

#+name: f:g
#+header: :yyy bar
#+begin_src haskell
g x = x + 1
#+end_src

#+type: figure
[[file:image/world.png]]

  - [[file:not-figure]]

[[file:not-a-figure-2]]

[[id:00000000-0000-0000-0000-000000000000::not a figure]]

#+type: figure
[[file:foo.png]]
t:0011-figures-and-blocks
shouldParseWithState
  "0011-figures-and-blocks"
  ( nilOrgDoc
      [ CBlock
          Block
            { parentOid = nilOid,
              meta =
                fromList
                  [ ("__block_type", "src"),
                    ("__src_language", "haskell"),
                    ("name", "f:f"),
                    ("xxx", "bar")
                  ],
              body = [BTokenString "f = 1 + 1"]
            },
        CFigure
          Figure
            { parentOid = nilOid,
              meta =
                fromList
                  [ ("__figure_number", "1"),
                    ("width", "100%"),
                    ("name", "fig:hello"),
                    ("caption", "=hello=")
                  ],
              caption =
                Prose
                  [ ptext "hello" [Verbatim]
                  ],
              link =
                Link
                  { target = LinkTargetFilePath "image/hello.png",
                    name = [stext "image/hello.png" []]
                  }
            },
        CBlock
          Block
            { parentOid = nilOid,
              meta =
                fromList
                  [ ("__block_type", "src"),
                    ("__src_language", "haskell"),
                    ("name", "f:g"),
                    ("yyy", "bar")
                  ],
              body = [BTokenString "g x = x + 1"]
            },
        CFigure
          Figure
            { parentOid = nilOid,
              meta =
                fromList
                  [ ("__figure_number", "2")
                  ],
              caption = Prose [],
              link =
                Link
                  { target = LinkTargetFilePath "image/world.png",
                    name = [stext "image/world.png" []]
                  }
            },
        CListItem
          [ LUnorderedStart,
            LItemStart
          ],
        CParagraph
          $ Prose
            [ PTokenLink
                Link
                  { target = LinkTargetFilePath "not-figure",
                    name = [stext "not-figure" []]
                  }
            ],
        CListItem
          [ LItemEnd,
            LUnorderedEnd
          ],
        CParagraph
          $ Prose
            [ PTokenLink
                Link
                  { target = LinkTargetFilePath "not-a-figure-2",
                    name = [stext "not-a-figure-2" []]
                  }
            ],
        CParagraph
          $ Prose
            [ PTokenLink
                Link
                  { target =
                      LinkTargetOid
                        LinkOid
                          { oid = nilOid,
                            name = "not a figure"
                          },
                    name = [stext "not a figure" []]
                  }
            ],
        CFigure
          Figure
            { parentOid = nilOid,
              meta =
                fromList
                  [ ("__figure_number", "3")
                  ],
              caption = Prose [],
              link =
                Link
                  { target = LinkTargetFilePath "foo.png",
                    name = [stext "foo.png" []]
                  }
            }
      ]
      [PseudoEdge (LCI 8, LinkOid {oid = fromWords 0 0, name = "not a figure"})]
  )
  defaultOrgParserState
    { cellNames =
        Set.fromList
          [ "f:f",
            "f:g",
            "fig:hello"
          ],
      figureNumber = 3
    }

Metadata keys that start with a double underscore are not allowed, because Lilac expects to use them for itself internally.

ti:0016-metadata-reserved-underscores
🎯 test/Lilac/ParseSpec/0016-metadata-reserved-underscores
#+__block_type: example
#+begin_src haskell
f = 1 + 1
#+end_src
t:0016-metadata-reserved-underscores
shouldFailWithMessage
  "0016-metadata-reserved-underscores"
  "cannot use leading double underscores for metadata key: \"__block_type\""
  2

15 Tables

Tables are like figures in that they have some metadata. Unlike a figure which is just a link, a table has a two-dimensional grid with some information in each row/column. The requirement is that the first non-whitespace character starts with a vertical bar |, also known as the pipe character (for piping shell commands together in UNIX).

Like figures, all tables are numbered. A table can also have a caption 165 (just like a data:Figure).

data:Table
type Table :: Type
data Table = Table
  { parentOid :: OID, -- 164
    meta :: Map Text Text,
    caption :: Prose, -- 165
    grid :: Map (Int, Int) Prose -- 166
  }
  deriving stock (Eq, Ord, Generic, Show)
  deriving anyclass (FromJSON, ToJSON)

The parentOid 164 is just like the one in data:Block.

Below is an example of a table:

#+name: tbl:Programming languages
#+caption: Programming languages and typing disciplines.
| Language | Typing          |
|----------+-----------------|
| LISP     | dynamic, strong |
| C        | static, weak    |
| Haskell  | static, strong  |

which is rendered in HTML as

Table 4. Programming languages and typing disciplines.
LanguageTyping
LISPdynamic, strong
Cstatic, weak
Haskellstatic, strong

Because we named the above with a #+name keyword, we can refer to it as [[tbl:Programming languages]] (tbl:Programming languages).

The f:tableParser parses tables.

f:tableParser
tableParser :: OrgParser Cell
tableParser = do
  pState <- get
  _ <- MP.try $ MP.lookAhead (metaParser >> tableRowParser)
  metaKVs <- metaParser
  caption <- case lookup "caption" metaKVs of
    Nothing -> pure $ Prose []
    Just s -> case MP.runParser (runStateT captionParser pState) "" s of -- 167
      Left err -> fail $ MP.errorBundlePretty err
      Right (prose, _pState) -> pure prose
  rows <- MP.some $ tableLineParser <|> tableRowParser
  _ <- MP.optional tableFormulaParser -- 168
  let newTableNumber = pState.tableNumber + 1
  modify (\s -> s {tableNumber = newTableNumber})
  pure
    . CTable
    $ Table
      { parentOid =
          firstParentOid pState.docOid pState.headingOidStack,
        meta = insert "__table_number" (T.show newTableNumber) metaKVs,
        caption = caption,
        grid = fromList $ rowsToTuples rows -- 169
      }

The f:captionParser parses the caption text from a #+caption: ... into prose. This way the caption can support other text styles, links, and even math fragments. We parse the text again by invoking MP.runParser 167, because it's easy to implement; a fancier alternative would be to enrich the f:metaParser to hold values that can be either raw text or prose.

f:captionParser
captionParser :: OrgParser Prose
captionParser = do
  caption <- proseParser $ whitespaceCompressor onlySpaces
  failIfLeftoverActiveStyles
  pure caption

The f:tableLineParser skips over horizontal rules (like |---+-----|) that typically separate the table heading (column names) from the rest of the rows that hold the actual data. It also skips over any leading indentation in the subsequent line, based on the continuationIndent which is set by f:listHeadIndentLevelParser. This way, we can parse tables inside list items.

f:tableLineParser
tableLineParser :: OrgParser [Prose]
tableLineParser = do
  _ <- string "|-"
  _ <- MP.takeWhile1P (Just "table line characters") isTableLineChar
  _ <- char '\n'
  pState <- get
  _ <-
    MP.label "indentation for next table line"
      $ MP.count pState.continuationIndent (char ' ')
  pure [tableHorizontalLine]
  where
    isTableLineChar c = c == '-' || c == '+' || c == '|'

The f:tableHorizontalLine is a special value to signify a horizontal line with the trailing \NUL byte in horizontal-line\NUL. This use of the \NUL is the only way we can guarantee that the user won't accidentally put in a value that looks like this in the table.

f:tableHorizontalLine
tableHorizontalLine :: Prose
tableHorizontalLine =
  Prose
    [ PTokenStyledText
        StyledText {body = "horizontal-line\NUL", style = styleMaskFrom []}
    ]

The f:tableRowParser parses rows with data in them. It also skips over indentation found in the subsequent line (if any). Unlike f:tableLineParser, we wrap this indentation parsing with MP.optional 170 because we cannot tell if there are more table rows after this or not. This is unlike the tableLineParser, which is never expected to be the last line of a table.

f:tableRowParser
tableRowParser :: OrgParser [Prose]
tableRowParser = do
  _ <- char '|'
  _ <- onlySpaces
  tableCells <- MP.some $ emptyTableCellParser <|> tableCellParser
  _ <- char '\n'
  pState <- get
  _ <-
    MP.optional -- 170
      . MP.label "indentation for next table line"
      $ MP.count pState.continuationIndent (char ' ')
  pure tableCells
  where
    emptyTableCellParser :: OrgParser Prose -- 171
    emptyTableCellParser = do
      _ <- char '|'
      _ <- MP.optional onlySpaces
      pure $ Prose []

The emptyTableCellParser 171 parses an empty table cell (because f:proseParser requires non-empty text). We need this in order to parse (essentially skip over) empty table cells, while still recording their existence. See how empty table cells are parsed in t:0012-tables-and-prose.

The f:tableCellParser parses prose text inside a single row and column combination --- which we call a table cell. The main idea is that we parse each table cell, which may not contain pipe characters in it (otherwise it confuses Orgmode in Emacs when we press TAB, which results in the table becoming formatted with extra columns that we don't want).

f:tableCellParser
tableCellParser :: OrgParser Prose
tableCellParser = do
  prose <-
    onlyMarkingCharacterParser
      <|> (proseParser $ whitespaceCompressor spacesInsideProse)
  _ <- MP.optional onlySpaces
  _ <- char '|'
  _ <- MP.optional onlySpaces
  pure $ compressProse prose
  where
    spacesInsideProse :: OrgParser Text -- 172
    spacesInsideProse = do
      _ <- MP.try $ MP.lookAhead (onlySpaces >> tableCellContent)
      onlySpaces
    tableCellContent :: OrgParser ()
    tableCellContent = void $ MP.noneOf (" \n|" :: String)
    onlyMarkingCharacterParser :: OrgParser Prose -- 173
    onlyMarkingCharacterParser = do
      _ <- MP.try . MP.lookAhead $ do
        _ <- MP.satisfy (`elem` markingChars)
        _ <- onlySpaces
        _ <- char '|'
        pure ()
      c <- MP.satisfy (`elem` markingChars)
      pure
        $ Prose
          [ PTokenStyledText
              StyledText {body = T.singleton c, style = styleMaskFrom []}
          ]

The spacesInsideProse 172 is a special space consumer inside table cells, where newlines and pipe characters are prohibited. This way, when f:proseParser uses it to find some contiguous amount of text, it will know to stop when it sees the pipe character.

The onlyMarkingCharacterParser 173 is there to parse f:markingChars which are special (optional) characters used only inside tables, but which share the same syntax as the characters used for styling prose, especially _, *, and /. These characters will break things if we let f:proseParser naively look at it, because that parser has no concept of table cell boundaries. The onlyMarkingCharacterParser parses one of these marking characters, and creates the corresponding newtype:Prose value. See this page for more information about marking characters.

After the rows are parsed, we need to convert them to a grid 166. We do this with f:rowsToTuples 169.

f:rowsToTuples
rowsToTuples :: [[Prose]] -> [((Int, Int), Prose)]
rowsToTuples rows = concatMap rowToTuples $ zip [1 ..] rows

We assign each row its row index with zip [1 ..], then delegate most of the work to f:rowToTuples where each row's table cells are given its column index to form a completed table cell. These are simply accumulated and returned as a list. The order of accumulation (consing) does not matter because this is ultimately fed into a Map type (where the keys are not guaranteed to be ordered).

f:rowToTuples
rowToTuples :: (Int, [Prose]) -> [((Int, Int), Prose)]
rowToTuples (rowIndex, row)
  | null row = [((rowIndex, 1), Prose [])]
  | otherwise = fst $ foldl' f ([], 1) row
  where
    f :: ([((Int, Int), Prose)], Int) -> Prose -> ([((Int, Int), Prose)], Int)
    f (acc, colIndex) tableCell =
      ( ((rowIndex, colIndex), tableCell) : acc,
        colIndex + 1
      )

The f:tableFormulaParser 168 checks for a table formula line (#+TBLFM: ...), which is neither useful for tangling nor weaving. These table formulas always come after a table, not before it. We need to be able to parse it in order to ignore it (otherwise we will think that the #+TBLFM: ... is part of another Org cell's metadata).

f:tableFormulaParser
tableFormulaParser :: OrgParser ()
tableFormulaParser = do
  _ <- string "#+"
  _ <- string "tblfm: " <|> string "TBLFM: "
  _ <- MP.takeWhile1P (Just "table formula") (/= '\n')
  _ <- char '\n'
  pure ()

15.1 Numeric table cells

It's useful to know if a table cell contains mostly numeric content, at the time of weaving (see isNumericText in f:weaveTable). This way, we can right-align the contents of that table cell, just like popular spreadsheet programs.

f:isNumericText parses the input through f:isNumericTextParser.

f:isNumericText
isNumericText :: Text -> Bool
isNumericText input = case MP.runParser
  (runStateT isNumericTextParser defaultOrgParserState)
  ""
  input of
  Left _ -> False
  Right (b, _) -> b

f:isNumericTextParser allows for a currency symbol, which can be either after or before a - or + sign. Then any combination of digits, periods, commas, and spaces are allowed. We check that there is at least 1 numeric digit in the string.

f:isNumericTextParser
isNumericTextParser :: OrgParser Bool
isNumericTextParser = do
  _ <- MP.optional sign
  _ <- MP.optional $ MP.satisfy isCurrencySymbol
  _ <- MP.optional sign
  s <- MP.some $ MP.satisfy isDigit <|> MP.satisfy (`elem` ['.', ',', ' '])
  MP.eof
  let numberFound = (> 0) . length $ filter isDigit s
  pure numberFound
  where
    isCurrencySymbol c = generalCategory c == CurrencySymbol
    sign :: OrgParser Char
    sign = MP.satisfy (`elem` ['-', '+'])

15.2 Tests

The test below checks if we can parse a table with all 3 types of prose text (styled text, links, and math fragments).

ti:0012-tables-and-prose
🎯 test/Lilac/ParseSpec/0012-tables-and-prose
#+name: tbl:famous-eqs
#+caption: *Bold*. [[tbl:famous-eqs][A self-link]]. \(E = mc^2\)
| Name            | Equation            | Link |
|-----------------+---------------------+------|
| Pythagoras \vert{} Foo | \(a^2 + b^2 = c^2\) | [[https://wiki/x][Wiki]] |
| Albert *Einstein* | \(E = mc^2\) | [[https://wiki/y][Wiki]] |
| Unknown           |  |  |
| _ | * | / |
#+tblfm: $x
t:0012-tables-and-prose
shouldParseWithState
  "0012-tables-and-prose"
  ( nilOrgDoc
      Expected cells for 0012-tables-and-prose
      []
  )
  defaultOrgParserState
    { cellNames =
        Set.fromList
          [ "tbl:famous-eqs"
          ],
      tableNumber = 1
    }
Expected cells for 0012-tables-and-prose
[ CTable
    Table
      { parentOid = nilOid,
        meta =
          fromList
            [ ("name", "tbl:famous-eqs"),
              ( "caption",
                "*Bold*. [[tbl:famous-eqs][A self-link]]. \\(E = mc^2\\)"
              ),
              ("__table_number", "1")
            ],
        caption =
          Prose
            [ ptext "Bold" [Bold],
              ptext "." [],
              ptext " " [],
              PTokenLink
                Link
                  { name =
                      [ stext "A" [],
                        stext " " [],
                        stext "self-link" []
                      ],
                    target =
                      LinkTargetOid
                        LinkOid {oid = nilOid, name = "tbl:famous-eqs"}
                  },
              ptext "." [],
              ptext " " [],
              PTokenMathFragment
                MathFragment
                  { parentOid = nilOid,
                    meta = fromList [],
                    body = "E = mc^2"
                  }
            ],
        grid =
          fromList
            [ ( (1, 1),
                Prose [ptext "Name" []]
              ),
              ( (1, 2),
                Prose [ptext "Equation" []]
              ),
              ( (1, 3),
                Prose [ptext "Link" []]
              ),
              ( (2, 1),
                L.tableHorizontalLine
              ),
              ( (3, 1),
                Prose [ptext "Pythagoras | Foo" []]
              ),
              ( (3, 2),
                Prose
                  [ PTokenMathFragment
                      MathFragment
                        { parentOid = nilOid,
                          meta = fromList [],
                          body = "a^2 + b^2 = c^2"
                        }
                  ]
              ),
              ( (3, 3),
                Prose
                  [ PTokenLink
                      Link
                        { target =
                            LinkTargetURI
                              N.URI
                                { N.uriScheme = "https:",
                                  N.uriAuthority =
                                    Just
                                      N.URIAuth
                                        { N.uriUserInfo = "",
                                          N.uriRegName = "wiki",
                                          N.uriPort = ""
                                        },
                                  N.uriPath = "/x",
                                  N.uriQuery = "",
                                  N.uriFragment = ""
                                },
                          name = [stext "Wiki" []]
                        }
                  ]
              ),
              ( (4, 1),
                Prose
                  [ ptext "Albert " [],
                    ptext "Einstein" [Bold]
                  ]
              ),
              ( (4, 2),
                Prose
                  [ PTokenMathFragment
                      MathFragment
                        { parentOid = nilOid,
                          meta = fromList [],
                          body = "E = mc^2"
                        }
                  ]
              ),
              ( (4, 3),
                Prose
                  [ PTokenLink
                      Link
                        { target =
                            LinkTargetURI
                              N.URI
                                { N.uriScheme = "https:",
                                  N.uriAuthority =
                                    Just
                                      N.URIAuth
                                        { N.uriUserInfo = "",
                                          N.uriRegName = "wiki",
                                          N.uriPort = ""
                                        },
                                  N.uriPath = "/y",
                                  N.uriQuery = "",
                                  N.uriFragment = ""
                                },
                          name = [stext "Wiki" []]
                        }
                  ]
              ),
              ((5, 1), Prose [ptext "Unknown" []]),
              ((5, 2), Prose []),
              ((5, 3), Prose []),
              ((6, 1), Prose [ptext "_" []]),
              ((6, 2), Prose [ptext "*" []]),
              ((6, 3), Prose [ptext "/" []])
            ]
      }
]

Below is the same as ti:0012-tables-and-prose, but for the case where we are indented as a list item.

ti:0012-tables-inside-lists
🎯 test/Lilac/ParseSpec/0012-tables-inside-lists
  1) foo

     #+name: tbl:famous-equations
     # This is a comment. The lexeme skips it.
     #+bar: baz
     | Name            | Equation            | Link |
     |-----------------+---------------------+------|
     | Pythagoras \vert{} Foo | \(a^2 + b^2 = c^2\) | [[https://wiki/x][Wiki]] |
     | Albert *Einstein* | \(E = mc^2\) | [[https://wiki/y][Wiki]] |
t:0012-tables-inside-lists
shouldParseWithState
  "0012-tables-inside-lists"
  ( nilOrgDoc
      Expected cells for 0012-tables-inside-lists
      []
  )
  defaultOrgParserState
    { cellNames =
        Set.fromList
          [ "tbl:famous-equations"
          ],
      tableNumber = 1
    }
Expected cells for 0012-tables-inside-lists
[ CListItem
    [ LOrderedStart,
      LItemStart
    ],
  CParagraph $ Prose [ptext "foo" []],
  CTable
    Table
      { parentOid = nilOid,
        meta =
          fromList
            [ ("name", "tbl:famous-equations"),
              ("bar", "baz"),
              ("__table_number", "1")
            ],
        caption = Prose [],
        grid =
          fromList
            [ ( (1, 1),
                Prose [ptext "Name" []]
              ),
              ( (1, 2),
                Prose [ptext "Equation" []]
              ),
              ( (1, 3),
                Prose [ptext "Link" []]
              ),
              ( (2, 1),
                L.tableHorizontalLine
              ),
              ( (3, 1),
                Prose [ptext "Pythagoras | Foo" []]
              ),
              ( (3, 2),
                Prose
                  [ PTokenMathFragment
                      MathFragment
                        { parentOid = nilOid,
                          meta = fromList [],
                          body = "a^2 + b^2 = c^2"
                        }
                  ]
              ),
              ( (3, 3),
                Prose
                  [ PTokenLink
                      Link
                        { target =
                            LinkTargetURI
                              N.URI
                                { N.uriScheme = "https:",
                                  N.uriAuthority =
                                    Just
                                      N.URIAuth
                                        { N.uriUserInfo = "",
                                          N.uriRegName = "wiki",
                                          N.uriPort = ""
                                        },
                                  N.uriPath = "/x",
                                  N.uriQuery = "",
                                  N.uriFragment = ""
                                },
                          name = [stext "Wiki" []]
                        }
                  ]
              ),
              ( (4, 1),
                Prose
                  [ ptext "Albert " [],
                    ptext "Einstein" [Bold]
                  ]
              ),
              ( (4, 2),
                Prose
                  [ PTokenMathFragment
                      MathFragment
                        { parentOid = nilOid,
                          meta = fromList [],
                          body = "E = mc^2"
                        }
                  ]
              ),
              ( (4, 3),
                Prose
                  [ PTokenLink
                      Link
                        { target =
                            LinkTargetURI
                              N.URI
                                { N.uriScheme = "https:",
                                  N.uriAuthority =
                                    Just
                                      N.URIAuth
                                        { N.uriUserInfo = "",
                                          N.uriRegName = "wiki",
                                          N.uriPort = ""
                                        },
                                  N.uriPath = "/y",
                                  N.uriQuery = "",
                                  N.uriFragment = ""
                                },
                          name = [stext "Wiki" []]
                        }
                  ]
              )
            ]
      },
  CListItem
    [ LItemEnd,
      LOrderedEnd
    ]
]

We shouldn't try to parse #not as a marking character.

ti:0012-non-marking-char
🎯 test/Lilac/ParseSpec/0012-non-marking-char
#not a comment

| foo            | bar            |
|----------------+----------------|
| #not a comment | #not-a-comment |
t:0012-non-marking-char
shouldParseWithState
  "0012-non-marking-char"
  ( nilOrgDoc
      Expected cells for 0012-non-marking-char
      []
  )
  defaultOrgParserState
    { tableNumber = 1
    }
Expected cells for 0012-non-marking-char
[ CParagraph
    $ Prose
      [ ptext "#not a comment" []
      ],
  CTable
    Table
      { parentOid = nilOid,
        meta =
          fromList
            [ ("__table_number", "1")
            ],
        caption = Prose [],
        grid =
          fromList
            [ ( (1, 1),
                Prose [ptext "foo" []]
              ),
              ( (1, 2),
                Prose [ptext "bar" []]
              ),
              ( (2, 1),
                L.tableHorizontalLine
              ),
              ( (3, 1),
                Prose [ptext "#not a comment" []]
              ),
              ( (3, 2),
                Prose [ptext "#not-a-comment" []]
              )
            ]
      }
]

16 Mathematical expressions

There are two ways to write math expressions, which we call math fragments: inline fragments (inside prose text), or on their own as standalone fragments. Let's look at standalone math fragments first.

16.1 Standalone math fragments

In vanilla Org, Latex fragments begin with \begin{<ENVIRONMENT>} and \end{<ENVIRONMENT>}, where <ENVIRONMENT> can be anything, not just for math. However those other non-mathematical facilities are not supported by Lilac. Because we only support math, we call them math fragments.

data:MathFragment
type MathFragment :: Type
data MathFragment = MathFragment
  { parentOid :: OID,
    meta :: Map Text Text,
    body :: Text
  }
  deriving stock (Eq, Generic, Ord, Show)
  deriving anyclass (FromJSON, ToJSON)

There are three ways to write math fragments: using \begin{equation} and \end{equation}, \[ ... \], or \( ... \). The first two ways requires them to start at the beginning of the line, while the third way allows them to be embedded inside prose text (and displayed inside the paragraph). The third way has is discussed in Inline math fragments.

We limit our discussion here to parsing the first two ways, which are standalone math fragments. They are called standalone because they are their own cells (as CMathFragment in f:mathFragmentStandaloneParser) and don't need to be part of another cell.

Use the first form for a standalone equation like this

\begin{equation}
2 + 2 = 4
\end{equation}

to get

\begin{equation}2 + 2 = 4\end{equation}

where the equation will get numbered and centered on its own. Use

\[2 + 2 = 4\]

to get

\[2 + 2 = 4\]

to achieve the same effect, but without numbering.

When using the standalone form, you can assign a #+name keyword to it to refer to the equation using a human-friendly name, like this:

#+name: eq:Pythagorean Theorem
\begin{equation}
a^2 + b^2 = c^2
\end{equation}

which becomes

\begin{equation}a^2 + b^2 = c^2\end{equation}

and you can link back to it using an internal link like eq:Pythagorean Theorem.

Let's first see how to parse \begin{equation} in f:mathFragmentNumberedParser.

f:mathFragmentNumberedParser
mathFragmentNumberedParser :: OrgParser MathFragment
mathFragmentNumberedParser = do
  pState <- get
  let number = pState.equationNumber + 1

  metaKVs <- metaParser
  _ <- lexeme $ string "\\begin{equation}"
  latexBody <-
    MP.someTill
      (MP.label "LaTeX standalone numbered math input" MP.anySingle)
      (string "\\end{equation}")

  modify (\s -> s {equationNumber = number})
  pure
    MathFragment
      { parentOid =
          firstParentOid pState.docOid pState.headingOidStack,
        meta =
          insert "__equation_number" (T.pack $ show number) metaKVs,
        body = trimTrailingWhitespace $ T.pack latexBody -- 174
      }

The call to f:trimTrailingWhitespace 174 cleans up any trailing whitespace we parsed into latexBody.

f:trimTrailingWhitespace
trimTrailingWhitespace :: Text -> Text
trimTrailingWhitespace = T.dropWhileEnd (\c -> T.elem c " \n\r\t")

Parsing unnumbered equations with f:mathFragmentUnnumberedParser is almost the same as f:mathFragmentNumberedParser.

f:mathFragmentUnnumberedParser
mathFragmentUnnumberedParser :: OrgParser MathFragment
mathFragmentUnnumberedParser = do
  pState <- get
  metaKVs <- metaParser
  _ <- lexeme $ string "\\["
  latex <-
    MP.someTill
      (MP.label "LaTeX standalone unnumbered math input" MP.anySingle)
      (string "\\]")
  anonNumber <- case lookup "name" metaKVs of
    Just _ -> pure Nothing
    Nothing -> do
      let n = pState.anonEquationNumber + 1
      modify (\s -> s {anonEquationNumber = n})
      pure $ Just n
  pure
    MathFragment
      { parentOid =
          firstParentOid pState.docOid pState.headingOidStack,
        meta = case anonNumber of
          Just n ->
            insert "__equation_anon_number" (T.pack $ show n) metaKVs
          Nothing -> metaKVs,
        body = trimTrailingWhitespace $ T.pack latex
      }

These numbered and unnumbered parsers are combined together in the top-level f:mathFragmentStandaloneParser.

f:mathFragmentStandaloneParser
mathFragmentStandaloneParser :: OrgParser Cell
mathFragmentStandaloneParser =
  CMathFragment
    <$> (MP.try mathFragmentNumberedParser <|> mathFragmentUnnumberedParser)

Unlike f:tableLineParser and f:tableRowParser, we don't use pState.continuationIndent to check which lines are part of the math fragment; we simply parse everything up until the end marker (\end{equation} or \]). This simplifies the parsing implementation. The downside is that we may accept input where the indentation across multiple math lines looks messy, but this is something the user should take care of anyway.

Let's check that we can parse multiple math fragments. We discard any trailing whitespace after the initial opening \begin{equation} or \[, as well as trailing whitespace for the contents inside (just before the \end{equation} marker).

ti:0010-standalone-math-fragments
🎯 test/Lilac/ParseSpec/0010-standalone-math-fragments
\begin{equation}
1 + 1 = 2
\end{equation}

\begin{equation}



1 + 1 = 2



\end{equation}

\[
2 + 2 = 4
\]

#+name: eq:x
\[


2 + 2 = 4
\]

#+name: eq:y
\begin{equation}
1 + 1 = 2
\end{equation}
t:0010-standalone-math-fragments
shouldParseWithState
  "0010-standalone-math-fragments"
  ( nilOrgDoc
      [ CMathFragment
          MathFragment
            { parentOid = nilOid,
              meta =
                fromList
                  [ ("__equation_number", "1")
                  ],
              body = "1 + 1 = 2"
            },
        CMathFragment
          MathFragment
            { parentOid = nilOid,
              meta =
                fromList
                  [ ("__equation_number", "2")
                  ],
              body = "1 + 1 = 2"
            },
        CMathFragment
          MathFragment
            { parentOid = nilOid,
              meta =
                fromList
                  [ ("__equation_anon_number", "1")
                  ],
              body = "2 + 2 = 4"
            },
        CMathFragment
          MathFragment
            { parentOid = nilOid,
              meta =
                fromList
                  [ ("name", "eq:x")
                  ],
              body = "2 + 2 = 4"
            },
        CMathFragment
          MathFragment
            { parentOid = nilOid,
              meta =
                fromList
                  [ ("__equation_number", "3"),
                    ("name", "eq:y")
                  ],
              body = "1 + 1 = 2"
            }
      ]
      []
  )
  defaultOrgParserState
    { equationNumber = 3,
      anonEquationNumber = 1,
      cellNames =
        Set.fromList
          [ "eq:x",
            "eq:y"
          ]
    }

Standalone math fragments may be included as part of a list item.

ti:0010-standalone-math-inside-lists
🎯 test/Lilac/ParseSpec/0010-standalone-math-inside-lists
  1) foo

     #+name: eq:x
     \[
     2 + 2 = 4
     \]

     #+name: eq:y
     \begin{equation}
     1 + 1 = 2
     \end{equation}
t:0010-standalone-math-inside-lists
shouldParseWithState
  "0010-standalone-math-inside-lists"
  ( nilOrgDoc
      [ CListItem
          [ LOrderedStart,
            LItemStart
          ],
        CParagraph $ Prose [ptext "foo" []],
        CMathFragment
          MathFragment
            { parentOid = nilOid,
              meta =
                fromList
                  [ ("name", "eq:x")
                  ],
              body = "2 + 2 = 4"
            },
        CMathFragment
          MathFragment
            { parentOid = nilOid,
              meta =
                fromList
                  [ ("__equation_number", "1"),
                    ("name", "eq:y")
                  ],
              body = "1 + 1 = 2"
            },
        CListItem
          [ LItemEnd,
            LOrderedEnd
          ]
      ]
      []
  )
  defaultOrgParserState
    { equationNumber = 1,
      cellNames =
        Set.fromList
          [ "eq:x",
            "eq:y"
          ]
    }

16.2 Inline math fragments

Inline math fragments are delimited by \( and \), such as in \(1 - 1 = 0\) which can be placed inside prose text (and displayed inside the paragraph) as in \(1 - 1 = 0\) .

f:mathFragmentInlineParser
mathFragmentInlineParser :: OrgParser MathFragment
mathFragmentInlineParser = do
  failIfVerbatimOrCode

  pState <- get
  _ <- string "\\("
  latex <- MP.someTill (MP.label "LaTeX math input" MP.anySingle) (string "\\)")
  pure
    MathFragment
      { parentOid =
          firstParentOid pState.docOid pState.headingOidStack,
        meta = fromList [], -- 175
        body = T.pack latex
      }

Just like f:linkParser, we fail the parser if we detect that verbatim or code styles are active.

We delegate the typesetting of the math to MathJax, which expects math input based on LaTeX syntax. So there's no additional work required here during the parsing stage. The reason why it still needs a meta value 175 is because we use the same data:MathFragment type.

ti:0010-inline-math
🎯 test/Lilac/ParseSpec/0010-inline-math
\(1 + 1\) is the same as \(2\), better known as \(1 + 1 = 2\).
t:0010-inline-math
shouldParseWithState
  "0010-inline-math"
  ( nilOrgDoc
      [ CParagraph
          $ Prose
            [ PTokenMathFragment
                MathFragment
                  { parentOid = nilOid,
                    meta = fromList [],
                    body = "1 + 1"
                  },
              ptext " is the same as " [],
              PTokenMathFragment
                MathFragment
                  { parentOid = nilOid,
                    meta = fromList [],
                    body = "2"
                  },
              ptext ", better known as " [],
              PTokenMathFragment
                MathFragment
                  { parentOid = nilOid,
                    meta = fromList [],
                    body = "1 + 1 = 2"
                  },
              ptext "." []
            ]
      ]
      []
  )
  defaultOrgParserState

17 Testing framework

The tests here are all designed to read from mostly fully-fledged Org source documents. (We say "mostly fully-fledged" because many of the test inputs don't have the required document OID in it (because it is redundant and largely meaningless for test cases that don't are about the document OID); these tests rely on f:nilOidOrgDocPrefix to be prefixed to the input.)

Anyway, there are some benefits of writing test inputs as self-contained Org documents this way:

  1. The raw input file (on disk) for test cases can be looked at if necessary. This is especially useful if the input has any special Org characters in it we have to escape. See ti:0007-blocks-ending and ti:0004-properties-drawer-empty where we need to escape some parts of the input with a leading comma (so as to distinguish between the Org syntax of this document versus the nested Org syntax of the input file we are defining).

  2. We can organize the input files in a systematic way. We use a NNNN-<description> naming scheme for the inputs, which all live under test/Lilac/ParseSpec. Each unique NNNN number groups together files meant to test similar functionality. This makes it easy to see at a glance what sorts of things we test.

The downside is that we have to perform lots of small read operations (IO) to run the unit tests. But this cost is small enough that we can ignore it (we spend far more time compiling the tests themselves).

17.1 Test module

module:Lilac.ParseSpec
🎯 test/Lilac/ParseSpec.hs
module Lilac.ParseSpec (spec) where

import Citeproc qualified as CP
import Data.Map.Strict qualified as M
import Data.Set qualified as Set
import Data.Text qualified as T
import Data.Void (Void)
import Lilac.Parse qualified as L
import Lilac.Types
  ( BToken (BTokenLinkTarget, BTokenString),
    Block (Block, body, meta, parentOid),
    Cell
      ( CBlock,
        CCommand,
        CFigure,
        CFootnote,
        CHeading,
        CListItem,
        CMathFragment,
        CParagraph,
        CTable
      ),
    Citation (Citation, cites, noteNumber, prefix, styleVariation, suffix),
    Cite (Cite, id, locator, prefix, suffix),
    Command (PrintBibliography, PrintGlossary),
    Figure (Figure, caption, link, meta, parentOid),
    Footnote (Footnote, body, name),
    Heading (Heading, body, level, meta, section),
    LCI (LCI),
    Link (Link, name, target),
    LinkOid (LinkOid, name, oid),
    LinkTarget
      ( LinkTargetFilePath,
        LinkTargetFootnote,
        LinkTargetLineLabel,
        LinkTargetOid,
        LinkTargetURI
      ),
    ListMarker
      ( LItemEnd,
        LItemStart,
        LOrderedEnd,
        LOrderedStart,
        LUnorderedEnd,
        LUnorderedStart
      ),
    MathFragment (MathFragment, body, meta, parentOid),
    OrgDoc
      ( OrgDoc,
        bibPaths,
        cells,
        citations,
        cslPath,
        footnoteRefs,
        glossary,
        lineLabels,
        meta,
        oid,
        pseudoEdges
      ),
    OrgParserConf (maxHeadingLevel),
    OrgParserState
      ( anonBlockNumber,
        anonEquationNumber,
        cellNames,
        docOid,
        equationNumber,
        figureNumber,
        footnoteDefinitions,
        footnoteNumber,
        footnoteRefs,
        glossary,
        headingLevel,
        headingOidStack,
        headingSectionNumber,
        lineLabelNumber,
        lineLabels,
        orgParserConf,
        prevCellIsFootnote,
        tableNumber,
        tanglePaths
      ),
    PToken (PTokenCitation, PTokenLink, PTokenMathFragment, PTokenStyledText),
    Prose (Prose),
    PseudoEdge (PseudoEdge),
    Style (Bold, Code, Italic, Underline, Verbatim),
    StyledText (StyledText, body, style),
    Table (Table, caption, grid, meta, parentOid),
    defaultOrgParserState,
    fromWords,
    nilOid,
    nilOidOrgDocPrefix,
    ptext,
    stext,
    styleMaskFrom,
  )
import Lilac.Util qualified as LU
import Network.URI qualified as N
import Relude
  ( Bool (False, True),
    FilePath,
    IO,
    Identity,
    Int,
    Maybe (Just, Nothing),
    String,
    Text,
    Type,
    forM_,
    fromList,
    repeat,
    runStateT,
    show,
    take,
    ($),
    (+),
    (.),
    (<>),
    (==),
  )
import Test.Hspec (Spec, before, describe, it, shouldBe)
import Test.Hspec.Megaparsec
  ( ET,
    elabel,
    err,
    errFancy,
    etoks,
    fancy,
    shouldFailOn,
    shouldFailWith,
    shouldParse,
    shouldSucceedOn,
    utoks,
  )
import Text.Megaparsec qualified as MP
import Text.Megaparsec.Char (char)

spec :: Spec -- 176
spec = do
  All test cases

ParseSpec helpers

We have to export a function named spec 176 for discovery of test cases to happen automatically.

17.2 Test helpers

Most of the test helpers are concerned about reading a raw input file from disk, and then running the f:orgDocParser against it.

The Parser type synonym 177 uses the same concrete type as we do for type:OrgParser. It is used in tests such as t:0005-notFollowedBy-behavior-for-empty-input.

We need a helper function around parseOrgDoc, specifically to read in a file from a given path. We name this helper f:shouldParseWith.

f:shouldParseWith
shouldParseWith :: FilePath -> OrgDoc -> Spec
shouldParseWith path expectedOrgDoc = do
  before (LU.readFrom "test/Lilac/ParseSpec/" path) $ do
    it path $ \input -> parseAs input path expected -- 178
  where
    expected = (expectedOrgDoc, defaultOrgParserState)

This helper in turn relies on f:parseAs 178. Notice that we prefix the input with f:nilOidOrgDocPrefix if the test case isn't concerned about the document OID. The test cases that do care about this have to check the behavior of the top-level properties drawer manually, such as in t:0002-doc-metadata-properties-drawer-clash.

f:parseAs
parseAs ::
  Text ->
  FilePath ->
  (OrgDoc, OrgParserState) ->
  IO ()
parseAs input path expected@(doc, _) =
  MP.runParser
    (runStateT L.orgDocParser defaultOrgParserState)
    path
    input'
    `shouldParse` expected
  where
    input' =
      if doc.oid == nilOid
        then nilOidOrgDocPrefix <> input
        else input

The f:nilOidOrgDocPrefix is technically valid enough to be its own complete Org document, as seen in t:empty-input.

f:nilOidOrgDocPrefix
nilOidOrgDocPrefix :: Text
nilOidOrgDocPrefix =
  """
  :PROPERTIES:
  :ID:       00000000-0000-0000-0000-000000000000
  :END:

  """

The f:nilOrgDoc helper is a basic "empty" Org document, used in many test cases as a nice starting point for the expected (parsed) output.

f:nilOrgDoc
nilOrgDoc :: [Cell] -> [PseudoEdge] -> OrgDoc
nilOrgDoc cells pseudoEdges =
  OrgDoc
    { oid = nilOid,
      cells = cells,
      pseudoEdges = pseudoEdges,
      footnoteRefs = M.empty,
      glossary = M.empty,
      lineLabels = M.empty,
      cslPath = Nothing,
      bibPaths = [],
      citations = [],
      meta = fromList [("ID", "00000000-0000-0000-0000-000000000000")]
    }

If we want to check the state (the parsing operation is stateful), we need to check the expected end state as well. We do this with f:shouldParseWithState, which is basically the same as f:shouldParseWith but with the addition of the OrgParserState parameter.

f:shouldParseWithState
shouldParseWithState :: FilePath -> OrgDoc -> OrgParserState -> Spec
shouldParseWithState path expectedOrgDoc expectedState = do
  before (LU.readFrom "test/Lilac/ParseSpec/" path) $ do
    it path $ \input -> parseAs input path expected
  where
    expected = (expectedOrgDoc, expectedState)

Sometimes we want to test that a file will fail to parse. We do this with f:shouldFailWithMessage.

f:shouldFailWithMessage
shouldFailWithMessage :: FilePath -> String -> Int -> Spec
shouldFailWithMessage path errMsg pos = do
  before (LU.readFrom "test/Lilac/ParseSpec/" path) $ do
    it path $ \input -> do
      MP.runParser
        (runStateT L.orgDocParser defaultOrgParserState)
        path
        (inputPrefix <> input)
        `shouldFailWith` (errFancy newPos . fancy $ MP.ErrorFail errMsg) -- 179
  where
    inputPrefix = nilOidOrgDocPrefix
    newPos = pos + T.length inputPrefix

We use the MP.ErrorFail constructor 179 because that's the only way we can check for failures in parsers where we manually return an error with Control.Monad.fail (as opposed to Megaparsec itself throwing the error because of a failure of the given parser combinators to account for the shape of the input text).

The f:shouldFailWithError helper checks for failures thrown by Megaparsec (the ones that f:shouldFailWithMessage do not check for). The ET likely stands for "trivial errors" or (error "trivial"), because it is documented in upstream as "Auxiliary type for construction of trivial parse errors.".

f:shouldFailWithError
shouldFailWithError :: FilePath -> ET Text -> Int -> Spec
shouldFailWithError path errorTrivial pos = do
  before (LU.readFrom "test/Lilac/ParseSpec/" path) $ do
    it path $ \input -> do
      MP.runParser
        (runStateT L.orgDocParser defaultOrgParserState)
        path
        (inputPrefix <> input)
        `shouldFailWith` (err newPos errorTrivial)
  where
    inputPrefix = nilOidOrgDocPrefix
    newPos = pos + T.length inputPrefix

The f:shouldFail helper simply asserts that the parse should fail, without regard to the exact failure message.

f:shouldFail
shouldFail :: FilePath -> Spec
shouldFail path = do
  before (LU.readFrom "test/Lilac/ParseSpec/" path) $ do
    it path $ \input -> do
      MP.runParser
        (runStateT L.orgDocParser defaultOrgParserState)
        path
        `shouldFailOn` (inputPrefix <> input)
  where
    inputPrefix = nilOidOrgDocPrefix

The f:plainParagraph function helps to cut down on some of the repetition.

f:plainParagraph
plainParagraph :: Text -> Cell
plainParagraph body =
  CParagraph
    $ Prose
      [ PTokenStyledText
          StyledText
            { body = body,
              style = styleMaskFrom []
            }
      ]

The same goes for function f:plainHeading.

f:plainHeading
plainHeading :: Int -> [Int] -> Cell
plainHeading level section =
  CHeading
    Heading
      { level = level,
        section = section,
        body =
          Prose
            [ PTokenStyledText
                StyledText
                  { body = T.pack $ show level,
                    style = styleMaskFrom []
                  }
            ],
        meta = fromList []
      }

17.3 All test cases

Below are all of the individual test cases.

All test cases
t:empty-input
t:self-parse
t:0001-blankline
t:0001-comments-only
t:0002-doc-metadata-properties-drawer-empty
t:0002-doc-metadata-missing-value
t:0002-doc-metadata-whitespace-before-value
t:0002-doc-metadata:basic
t:0003-doc-metadata-with-comments
t:0002-doc-metadata-properties-drawer
t:0002-doc-metadata-properties-drawer-combine
t:0002-doc-metadata-properties-drawer-clash
t:0004-headings
t:0004-headings-20
t:0004-headings-unlimited
t:0004-headings-limited
t:0004-headings-prohibited
t:0004-headings-cannot-skip-level
t:Generate headings
t:Generate Org ID stack
t:0004-properties-drawer-empty
t:0004-properties-drawer-not-empty
t:0004-properties-drawer-not-empty-with-comments
t:0005-paragraph-indentation-mismatch-initial-indent-invalid-between-levels
t:0005-paragraph-indentation-mismatch-initial-indent-invalid
t:0005-paragraph-indentation-mismatch-over-list-body
t:0005-paragraph-indentation-mismatch-over
t:0005-paragraph-indentation-mismatch-under-list-body
t:0005-prose-paragraphs
t:0005-notFollowedBy-behavior-for-empty-input
t:0005-styled-strings-bold
t:0005-styled-strings-complex-punctuation
t:0005-styled-strings-slash-separated-words
t:0005-styled-strings-complex
t:0005-styled-strings-compression
t:0005-styled-strings-escape-marker
t:0005-styled-strings-leftover-active-styles
t:0005-styled-strings-messy-boundaries
t:0005-styled-strings-no-style
t:0005-styled-strings-verbatim-code-ignore-immediate-bold
t:0005-styled-strings-verbatim-precedence
t:0005-styled-strings-verbatim
t:0005-styled-strings-escaping
t:0006-links-inside-parentheses
t:0006-links-nonlink
t:0006-links-raw
t:0006-links
t:0006-links-web-protocols
t:0006-links-oids
t:0006-links-oids-lenient-whitespace
t:0006-links-oids-line-labels
t:0006-unique-line-labels
t:0007-block-src-without-lang
t:0007-blocks-empty
t:0007-blocks-ending
t:0007-blocks-noweb-off
t:0007-blocks-noweb
t:0007-blocks-noweb-intraline
t:0007-blocks-duplicate-names
t:0007-blocks-duplicate-tangle-paths
t:0007-blocks-special-commas
t:0007-blocks-no-name-with-opening-parenthesis
t:0008-indentation-comment
t:0008-indentation-headings
t:0008-indentation-paragraph
t:0009-list-content-simple
t:0009-list-indentation-nested-with-text
t:0009-list-indentation-nested
t:0009-list-nesting-max
t:0009-list-nesting-max-8
t:0009-list-indentation-no-indent
t:0009-list-indentation-proper-indent
t:0009-list-items-need-blank-line-separator
t:0009-lists-cannot-be-mixed-on-their-own-ordered
t:0009-lists-cannot-be-mixed-on-their-own
t:0009-lists-require-precise-indentation
t:0009-ordered-lists-require-increment
t:0009-ordered-lists-start-from-1
t:0009-ordered-list-head-needs-parenthesis
t:0009-ordered-list-head-cannot-use-letters
t:0009-paragraph-can-close-deeper-levels
t:0009-paragraph-can-separate-mixed-lists
t:0010-standalone-math-fragments
t:0010-standalone-math-inside-lists
t:0010-inline-math
t:0011-figures-and-blocks
t:0012-tables-and-prose
t:0012-tables-inside-lists
t:0012-non-marking-char
t:0013-footnotes-broken-link
t:0013-footnotes-unused-def
t:0013-footnotes-simple
t:0013-footnotes-complex
t:0013-footnotes-multiple-groups
t:0014-citations-csl-path
t:0014-citations-csl-json-paths
t:0014-citations-bibliography-location
t:0014-citations-single-cite
t:0014-citations-locators
t:0014-citations-locators-affixes
t:0014-citations-worg
t:0014-citations-unbalanced-brackets-prefix-opening
t:0014-citations-unbalanced-brackets-prefix-closing
t:0014-citations-unbalanced-brackets-suffix-opening
t:0014-citations-unbalanced-brackets-suffix-closing
t:0014-citations-extra-semicolons-basic
t:0014-citations-extra-semicolons-consecutive
t:0014-citations-nested
t:0014-citations-forbidden-in-verbatim-code
t:0014-citations-forbidden-in-headings
Text to Prose round trip
Drop text chars from Prose
t:0015-glossary-duplicate-term
t:0015-glossary-location
t:0015-glossary-location-global
t:0016-metadata-reserved-underscores

18 Glossary

a name
what's displayed to the reader, and
a target
where we want to take the reader when they click on the link.
caption
(Optional) Displayed as the caption for the image. If there is no caption, on weaving the only name of the figure will be just the figure number, like Figure 1 or Figure 12. You can use styled text (bold, etc), links, and inline math, just like regular paragraphs (the only caveat is that you are not allowed to have newlines).
height
(Optional) Height of the image (as a percentage of page width or pixel count), for HTML weaving.
lilac_maxHeadingLevel
f:maxHeadingLevelParser This takes an integer as a value. Using -1 means no headings are allowed at all. Using 0 means headings can have as many asterisks as desired. Using a positive integer means headings may only have that many (or less) asterisks; otherwise Lilac will fail the parse.
lilac_maxListLevel
f:maxListLevelParser This has the same semantics as lilac_maxHeadingLevel but is for list items, not headings.
name
(Optional) Used to name the cell uniquely in the surrounding Org file. Other cells can refer to this figure by this name (or as part of a global data:LinkOid). The name is optional because all figures, named or not, are always assigned a number like Figure 1 or Figure 12.
type
(Required) Used to force there to be at least 1 key/value pair in the metadata. Otherwise when the parser comes across a link to an image on its own without metadata, it is ambiguous whether the link is part of a 1-link paragraph cell, or as part of a figure cell.
width
(Optional) Width of the image (as a percentage of page width or pixel count), for HTML weaving.

19 Footnotes

1. We deviate from the terminology used in the Org syntax description, where the terms element and object are used for what we call a cell. This is to avoid any unintentional overlap of meaning. ↑ ¶ (Cell 2)

2. For example, see the implementation for GHC 9.12 (found via base-4.21.0.0). ↑ ¶ (Cell 740)

3. We're supposed to use things like MP.region or MP.parseError according to the Megaparsec tutorial, but it doesn't seem to work (at least with the way we've constructed things with relation to our use of f:listItemsEndParser in f:cellParser). ↑ ¶ (Cell 1348)

Page metrics

Tangled files (111)

  1. src/Lilac/Parse.hs
  2. test/Lilac/ParseSpec.hs
  3. test/Lilac/ParseSpec/0001-blankline
  4. test/Lilac/ParseSpec/0001-comments-only
  5. test/Lilac/ParseSpec/0002-doc-metadata
  6. test/Lilac/ParseSpec/0002-doc-metadata-missing-value
  7. test/Lilac/ParseSpec/0002-doc-metadata-properties-drawer
  8. test/Lilac/ParseSpec/0002-doc-metadata-properties-drawer-clash
  9. test/Lilac/ParseSpec/0002-doc-metadata-properties-drawer-combine
  10. test/Lilac/ParseSpec/0002-doc-metadata-whitespace-before-value
  11. test/Lilac/ParseSpec/0003-doc-metadata-with-comments
  12. test/Lilac/ParseSpec/0004-headings
  13. test/Lilac/ParseSpec/0004-headings-20
  14. test/Lilac/ParseSpec/0004-headings-cannot-skip-level
  15. test/Lilac/ParseSpec/0004-headings-limited
  16. test/Lilac/ParseSpec/0004-headings-prohibited
  17. test/Lilac/ParseSpec/0004-headings-unlimited
  18. test/Lilac/ParseSpec/0004-properties-drawer-empty
  19. test/Lilac/ParseSpec/0004-properties-drawer-not-empty
  20. test/Lilac/ParseSpec/0004-properties-drawer-not-empty-with-comments
  21. test/Lilac/ParseSpec/0005-paragraph-indentation-mismatch-initial-indent-invalid
  22. test/Lilac/ParseSpec/0005-paragraph-indentation-mismatch-initial-indent-invalid-between-levels
  23. test/Lilac/ParseSpec/0005-paragraph-indentation-mismatch-over
  24. test/Lilac/ParseSpec/0005-paragraph-indentation-mismatch-over-list-body
  25. test/Lilac/ParseSpec/0005-paragraph-indentation-mismatch-under-list-body
  26. test/Lilac/ParseSpec/0005-prose-paragraphs
  27. test/Lilac/ParseSpec/0005-styled-strings-bold
  28. test/Lilac/ParseSpec/0005-styled-strings-complex
  29. test/Lilac/ParseSpec/0005-styled-strings-complex-punctuation
  30. test/Lilac/ParseSpec/0005-styled-strings-compression
  31. test/Lilac/ParseSpec/0005-styled-strings-escape-marker
  32. test/Lilac/ParseSpec/0005-styled-strings-escaping
  33. test/Lilac/ParseSpec/0005-styled-strings-leftover-active-styles
  34. test/Lilac/ParseSpec/0005-styled-strings-messy-boundaries
  35. test/Lilac/ParseSpec/0005-styled-strings-no-style
  36. test/Lilac/ParseSpec/0005-styled-strings-slash-separated-words
  37. test/Lilac/ParseSpec/0005-styled-strings-verbatim
  38. test/Lilac/ParseSpec/0005-styled-strings-verbatim-code-ignore-immediate-bold
  39. test/Lilac/ParseSpec/0005-styled-strings-verbatim-precedence
  40. test/Lilac/ParseSpec/0006-links
  41. test/Lilac/ParseSpec/0006-links-inside-parentheses
  42. test/Lilac/ParseSpec/0006-links-nonlink
  43. test/Lilac/ParseSpec/0006-links-oids
  44. test/Lilac/ParseSpec/0006-links-oids-lenient-whitespace
  45. test/Lilac/ParseSpec/0006-links-oids-line-labels
  46. test/Lilac/ParseSpec/0006-links-raw
  47. test/Lilac/ParseSpec/0006-links-web-protocols
  48. test/Lilac/ParseSpec/0006-unique-line-labels
  49. test/Lilac/ParseSpec/0007-block-src-without-lang
  50. test/Lilac/ParseSpec/0007-blocks-duplicate-names
  51. test/Lilac/ParseSpec/0007-blocks-duplicate-tangle-paths
  52. test/Lilac/ParseSpec/0007-blocks-empty
  53. test/Lilac/ParseSpec/0007-blocks-ending
  54. test/Lilac/ParseSpec/0007-blocks-no-name-with-opening-parenthesis
  55. test/Lilac/ParseSpec/0007-blocks-noweb
  56. test/Lilac/ParseSpec/0007-blocks-noweb-intraline
  57. test/Lilac/ParseSpec/0007-blocks-noweb-off
  58. test/Lilac/ParseSpec/0007-blocks-special-commas
  59. test/Lilac/ParseSpec/0008-indentation-comment
  60. test/Lilac/ParseSpec/0008-indentation-headings
  61. test/Lilac/ParseSpec/0008-indentation-paragraph
  62. test/Lilac/ParseSpec/0009-list-content-simple
  63. test/Lilac/ParseSpec/0009-list-indentation-nested
  64. test/Lilac/ParseSpec/0009-list-indentation-nested-with-text
  65. test/Lilac/ParseSpec/0009-list-indentation-no-indent
  66. test/Lilac/ParseSpec/0009-list-indentation-proper-indent
  67. test/Lilac/ParseSpec/0009-list-items-need-blank-line-separator
  68. test/Lilac/ParseSpec/0009-list-nesting-max
  69. test/Lilac/ParseSpec/0009-list-nesting-max-8
  70. test/Lilac/ParseSpec/0009-lists-cannot-be-mixed-on-their-own
  71. test/Lilac/ParseSpec/0009-lists-cannot-be-mixed-on-their-own-ordered
  72. test/Lilac/ParseSpec/0009-lists-prohibited
  73. test/Lilac/ParseSpec/0009-lists-require-precise-indentation
  74. test/Lilac/ParseSpec/0009-ordered-list-head-cannot-use-letters
  75. test/Lilac/ParseSpec/0009-ordered-list-head-needs-parenthesis
  76. test/Lilac/ParseSpec/0009-ordered-lists-require-increment
  77. test/Lilac/ParseSpec/0009-ordered-lists-start-from-1
  78. test/Lilac/ParseSpec/0009-paragraph-can-close-deeper-levels
  79. test/Lilac/ParseSpec/0009-paragraph-can-separate-mixed-lists
  80. test/Lilac/ParseSpec/0010-inline-math
  81. test/Lilac/ParseSpec/0010-standalone-math-fragments
  82. test/Lilac/ParseSpec/0010-standalone-math-inside-lists
  83. test/Lilac/ParseSpec/0011-figures-and-blocks
  84. test/Lilac/ParseSpec/0012-non-marking-char
  85. test/Lilac/ParseSpec/0012-tables-and-prose
  86. test/Lilac/ParseSpec/0012-tables-inside-lists
  87. test/Lilac/ParseSpec/0013-footnotes-broken-link
  88. test/Lilac/ParseSpec/0013-footnotes-complex
  89. test/Lilac/ParseSpec/0013-footnotes-multiple-groups
  90. test/Lilac/ParseSpec/0013-footnotes-simple
  91. test/Lilac/ParseSpec/0013-footnotes-unused-def
  92. test/Lilac/ParseSpec/0014-citations-bibliography-location
  93. test/Lilac/ParseSpec/0014-citations-csl-json-paths
  94. test/Lilac/ParseSpec/0014-citations-csl-path
  95. test/Lilac/ParseSpec/0014-citations-extra-semicolons-basic
  96. test/Lilac/ParseSpec/0014-citations-extra-semicolons-consecutive
  97. test/Lilac/ParseSpec/0014-citations-forbidden-in-headings
  98. test/Lilac/ParseSpec/0014-citations-forbidden-in-verbatim-code
  99. test/Lilac/ParseSpec/0014-citations-locators
  100. test/Lilac/ParseSpec/0014-citations-locators-affixes
  101. test/Lilac/ParseSpec/0014-citations-nested
  102. test/Lilac/ParseSpec/0014-citations-single-cite
  103. test/Lilac/ParseSpec/0014-citations-unbalanced-brackets-prefix-closing
  104. test/Lilac/ParseSpec/0014-citations-unbalanced-brackets-prefix-opening
  105. test/Lilac/ParseSpec/0014-citations-unbalanced-brackets-suffix-closing
  106. test/Lilac/ParseSpec/0014-citations-unbalanced-brackets-suffix-opening
  107. test/Lilac/ParseSpec/0014-citations-worg
  108. test/Lilac/ParseSpec/0015-glossary-duplicate-term
  109. test/Lilac/ParseSpec/0015-glossary-location
  110. test/Lilac/ParseSpec/0015-glossary-location-global
  111. test/Lilac/ParseSpec/0016-metadata-reserved-underscores

Named cells (471)

  1. All test cases
  2. CiteprocOutput typeclass
  3. Concrete type for Megaparsec
  4. Drop text chars from Prose
  5. Expected blocks for 0007-blocks-empty
  6. Expected blocks for 0007-blocks-ending
  7. Expected blocks for 0007-blocks-noweb
  8. Expected blocks for 0007-blocks-noweb-intraline
  9. Expected blocks for 0007-blocks-noweb-off
  10. Expected blocks for 0007-blocks-special-commas
  11. Expected cells for 0012-non-marking-char
  12. Expected cells for 0012-tables-and-prose
  13. Expected cells for 0012-tables-inside-lists
  14. Expected cells for 0013-footnotes-complex
  15. Expected cells for 0013-footnotes-simple
  16. Expected prose for 0005-prose-paragraphs
  17. Expected prose for 0005-styled-strings-complex
  18. Expected prose for 0005-styled-strings-complex-punctuation
  19. Expected prose for 0005-styled-strings-compression
  20. Expected prose for 0005-styled-strings-escape-marker
  21. Expected prose for 0005-styled-strings-escaping
  22. Expected prose for 0005-styled-strings-messy-boundaries
  23. Expected prose for 0005-styled-strings-slash-separated-words
  24. Expected prose for 0005-styled-strings-verbatim
  25. Expected prose for 0005-styled-strings-verbatim-code-ignore-immediate-bold
  26. Expected prose for 0005-styled-strings-verbatim-precedence
  27. Expected prose for 0014-citations-forbidden-in-verbatim-code
  28. Expected prose for 0014-citations-locators
  29. Expected prose for 0014-citations-locators-affixes
  30. Expected prose for 0014-citations-single-cite
  31. Expected prose for 0014-citations-worg
  32. Footnote (fn:cell)
  33. Footnote (fn:megaparsec-errors)
  34. Footnote (fn:semigroup-monoid-for-list)
  35. ParseSpec helpers
  36. Text to Prose round trip
  37. data:BToken
  38. data:Block
  39. data:Cell
  40. data:Citation
  41. data:Cite
  42. data:Command
  43. data:Figure
  44. data:Footnote
  45. data:GlossaryTerm
  46. data:Heading
  47. data:Level
  48. data:Link
  49. data:LinkOid
  50. data:LinkTarget
  51. data:ListMarker
  52. data:MathFragment
  53. data:OrgDoc
  54. data:OrgParserConf
  55. data:OrgParserState
  56. data:PToken
  57. data:Style
  58. data:StyledText
  59. data:Table
  60. eq:Pythagorean Theorem
  61. f:assertUniqueCellName
  62. f:assertUniqueGlossaryTerm
  63. f:assertUniqueLineLabel
  64. f:assertUniqueTanglePath
  65. f:bTokenParser
  66. f:bibliographyLocationParser
  67. f:blockMetaEntryParser
  68. f:blockParser
  69. f:blockStartParser
  70. f:captionParser
  71. f:cellBoundary
  72. f:cellParser
  73. f:checkTextToProseRoundTrip
  74. f:citationParser
  75. f:citeAffixParser
  76. f:citeIdParser
  77. f:citeLocatorCoordParser
  78. f:citeLocatorCoordRangeParser
  79. f:citeLocatorCoordsParser
  80. f:citeLocatorParser
  81. f:citeLocatorTermParser
  82. f:citeParser
  83. f:collectCitations
  84. f:collectValsFor
  85. f:compressBTokens
  86. f:compressProse
  87. f:compressStyledText
  88. f:compressedTextParser
  89. f:convertToGlossaryTerm
  90. f:defaultOrgDoc
  91. f:defaultOrgParserConf
  92. f:defaultOrgParserState
  93. f:deleteRedundantKeys
  94. f:detectInvalidIndentation
  95. f:docMetaEntryParser
  96. f:docMetaParser
  97. f:dropTextFromStyledText
  98. f:escapedSymbolParser
  99. f:failIfHeadingNestsWithoutDirectParent
  100. f:failIfLeftoverActiveStyles
  101. f:failIfUnbalancedBrackets
  102. f:failIfVerbatimOrCode
  103. f:figureParser
  104. f:fileLinkParser
  105. f:firstParentOid
  106. f:footnoteDefinitionParser
  107. f:footnoteLinkParser
  108. f:footnoteReferenceParser
  109. f:genHeadingOidStack
  110. f:genSectionNumber
  111. f:getBlockContents
  112. f:getContinuationIndentForLevel
  113. f:getEndingListMarkers
  114. f:getLinkPaddingBools
  115. f:glossaryLocationParser
  116. f:headingParser
  117. f:ignoreSpecialCommas
  118. f:intraCellWhitespaceParser
  119. f:isAlphaChar
  120. f:isAlphaNumChar
  121. f:isCode
  122. f:isDigitChar
  123. f:isEmptyMask
  124. f:isGlossaryTermDelimiter
  125. f:isLineLabel
  126. f:isNumericText
  127. f:isNumericTextParser
  128. f:isPunctuation
  129. f:isStyleMarker
  130. f:isVerbatim
  131. f:lexeme
  132. f:lilacExtensionsParser
  133. f:lineLabelParser
  134. f:linkDisplayNameParser
  135. f:linkParser
  136. f:listHeadIndentLevelParser
  137. f:listItemFitter
  138. f:listItemNester
  139. f:listItemStartParser
  140. f:listItemsEndParser
  141. f:maskToStyles
  142. f:mathFragmentInlineParser
  143. f:mathFragmentNumberedParser
  144. f:mathFragmentStandaloneParser
  145. f:mathFragmentUnnumberedParser
  146. f:maxHeadingLevelParser
  147. f:maxListLevelParser
  148. f:metaEntryParser
  149. f:metaParser
  150. f:newlineAndIndentParser
  151. f:nilOidOrgDocPrefix
  152. f:nilOrgDoc
  153. f:nowebLinkAtEndOfLine
  154. f:nowebLinkTargetParser
  155. f:oidLinkParser
  156. f:oidReferenceParser
  157. f:onlySpaces
  158. f:orderedHeadParser
  159. f:orgDocParser
  160. f:padConsecutiveNowebLinks
  161. f:paragraphParser
  162. f:parseAs
  163. f:parseOrgDoc
  164. f:plainHeading
  165. f:plainParagraph
  166. f:propertiesDrawerParser
  167. f:propertiesEntryParser
  168. f:propertiesParser
  169. f:proseParser
  170. f:proseSpaceConsumer
  171. f:protocolWebLinkParser
  172. f:ptext
  173. f:rawLinkParser
  174. f:reconcileFootnotes
  175. f:reconcileLineLabels
  176. f:resetState
  177. f:rowToTuples
  178. f:rowsToTuples
  179. f:shouldFail
  180. f:shouldFailWithError
  181. f:shouldFailWithMessage
  182. f:shouldParseWith
  183. f:shouldParseWithState
  184. f:signedIntParser
  185. f:skipNewlinesAndComments
  186. f:slashSeparatedWords
  187. f:someBTokensParser
  188. f:someIndent
  189. f:stext
  190. f:styleMarkerParser
  191. f:styleMarkers
  192. f:styleMaskFlip
  193. f:styleMaskFrom
  194. f:styledTextParser
  195. f:tableCellParser
  196. f:tableFormulaParser
  197. f:tableHorizontalLine
  198. f:tableLineParser
  199. f:tableParser
  200. f:tableRowParser
  201. f:textParser
  202. f:toStyleMask
  203. f:trimTrailingWhitespace
  204. f:unorderedHeadParser
  205. f:unparseLink
  206. f:unparseMathFragment
  207. f:unparseStyledText
  208. f:validateCslPath
  209. f:validateOidText
  210. f:verbatimCodeParser
  211. f:whitespaceCompressor
  212. f:zeroBTokensParser
  213. f:zeroIndent
  214. fig:PNG image
  215. instance:CiteprocOutput Prose
  216. instance:CiteprocOutput Prose:addDisplay
  217. instance:CiteprocOutput Prose:addFontStyle
  218. instance:CiteprocOutput Prose:addFontVariant
  219. instance:CiteprocOutput Prose:addFontWeight
  220. instance:CiteprocOutput Prose:addHyperlink
  221. instance:CiteprocOutput Prose:addQuotes
  222. instance:CiteprocOutput Prose:addTextCase
  223. instance:CiteprocOutput Prose:addTextDecoration
  224. instance:CiteprocOutput Prose:addVerticalAlign
  225. instance:CiteprocOutput Prose:fromText
  226. instance:CiteprocOutput Prose:inNote
  227. instance:CiteprocOutput Prose:localizeQuotes
  228. instance:CiteprocOutput Prose:mapText
  229. instance:CiteprocOutput Prose:movePunctuationInsideQuotes
  230. instance:CiteprocOutput Prose:toText
  231. instance:Monoid Prose
  232. instance:Semigroup Prose
  233. instance:Show LinkOid
  234. instance:Show Stylemask
  235. link-marker
  236. module:Lilac.Parse
  237. module:Lilac.ParseSpec
  238. newtype:LCI
  239. newtype:OID
  240. newtype:Prose
  241. newtype:PseudoEdge
  242. newtype:StyleMask
  243. orgmode-citation-breakage-workaround
  244. some message
  245. t:0001-blankline
  246. t:0001-comments-only
  247. t:0002-doc-metadata-missing-value
  248. t:0002-doc-metadata-properties-drawer
  249. t:0002-doc-metadata-properties-drawer-clash
  250. t:0002-doc-metadata-properties-drawer-combine
  251. t:0002-doc-metadata-properties-drawer-empty
  252. t:0002-doc-metadata-whitespace-before-value
  253. t:0002-doc-metadata:basic
  254. t:0003-doc-metadata-with-comments
  255. t:0004-headings
  256. t:0004-headings-20
  257. t:0004-headings-cannot-skip-level
  258. t:0004-headings-limited
  259. t:0004-headings-prohibited
  260. t:0004-headings-unlimited
  261. t:0004-properties-drawer-empty
  262. t:0004-properties-drawer-not-empty
  263. t:0004-properties-drawer-not-empty-with-comments
  264. t:0005-notFollowedBy-behavior-for-empty-input
  265. t:0005-paragraph-indentation-mismatch-initial-indent-invalid
  266. t:0005-paragraph-indentation-mismatch-initial-indent-invalid-between-levels
  267. t:0005-paragraph-indentation-mismatch-over
  268. t:0005-paragraph-indentation-mismatch-over-list-body
  269. t:0005-paragraph-indentation-mismatch-under-list-body
  270. t:0005-prose-paragraphs
  271. t:0005-styled-strings-bold
  272. t:0005-styled-strings-complex
  273. t:0005-styled-strings-complex-punctuation
  274. t:0005-styled-strings-compression
  275. t:0005-styled-strings-escape-marker
  276. t:0005-styled-strings-escaping
  277. t:0005-styled-strings-leftover-active-styles
  278. t:0005-styled-strings-messy-boundaries
  279. t:0005-styled-strings-no-style
  280. t:0005-styled-strings-slash-separated-words
  281. t:0005-styled-strings-verbatim
  282. t:0005-styled-strings-verbatim-code-ignore-immediate-bold
  283. t:0005-styled-strings-verbatim-precedence
  284. t:0006-links
  285. t:0006-links-inside-parentheses
  286. t:0006-links-nonlink
  287. t:0006-links-oids
  288. t:0006-links-oids-lenient-whitespace
  289. t:0006-links-oids-line-labels
  290. t:0006-links-raw
  291. t:0006-links-web-protocols
  292. t:0006-unique-line-labels
  293. t:0007-block-src-without-lang
  294. t:0007-blocks-duplicate-names
  295. t:0007-blocks-duplicate-tangle-paths
  296. t:0007-blocks-empty
  297. t:0007-blocks-ending
  298. t:0007-blocks-no-name-with-opening-parenthesis
  299. t:0007-blocks-noweb
  300. t:0007-blocks-noweb-intraline
  301. t:0007-blocks-noweb-off
  302. t:0007-blocks-special-commas
  303. t:0008-indentation-comment
  304. t:0008-indentation-headings
  305. t:0008-indentation-paragraph
  306. t:0009-list-content-simple
  307. t:0009-list-indentation-nested
  308. t:0009-list-indentation-nested-with-text
  309. t:0009-list-indentation-no-indent
  310. t:0009-list-indentation-proper-indent
  311. t:0009-list-items-need-blank-line-separator
  312. t:0009-list-nesting-max
  313. t:0009-list-nesting-max-8
  314. t:0009-lists-cannot-be-mixed-on-their-own
  315. t:0009-lists-cannot-be-mixed-on-their-own-ordered
  316. t:0009-lists-prohibited
  317. t:0009-lists-require-precise-indentation
  318. t:0009-ordered-list-head-cannot-use-letters
  319. t:0009-ordered-list-head-needs-parenthesis
  320. t:0009-ordered-lists-require-increment
  321. t:0009-ordered-lists-start-from-1
  322. t:0009-paragraph-can-close-deeper-levels
  323. t:0009-paragraph-can-separate-mixed-lists
  324. t:0010-inline-math
  325. t:0010-standalone-math-fragments
  326. t:0010-standalone-math-inside-lists
  327. t:0011-figures-and-blocks
  328. t:0012-non-marking-char
  329. t:0012-tables-and-prose
  330. t:0012-tables-inside-lists
  331. t:0013-footnotes-broken-link
  332. t:0013-footnotes-complex
  333. t:0013-footnotes-multiple-groups
  334. t:0013-footnotes-simple
  335. t:0013-footnotes-unused-def
  336. t:0014-citations-bibliography-location
  337. t:0014-citations-csl-json-paths
  338. t:0014-citations-csl-path
  339. t:0014-citations-extra-semicolons-basic
  340. t:0014-citations-extra-semicolons-consecutive
  341. t:0014-citations-forbidden-in-headings
  342. t:0014-citations-forbidden-in-verbatim-code
  343. t:0014-citations-locators
  344. t:0014-citations-locators-affixes
  345. t:0014-citations-nested
  346. t:0014-citations-single-cite
  347. t:0014-citations-unbalanced-brackets-prefix-closing
  348. t:0014-citations-unbalanced-brackets-prefix-opening
  349. t:0014-citations-unbalanced-brackets-suffix-closing
  350. t:0014-citations-unbalanced-brackets-suffix-opening
  351. t:0014-citations-worg
  352. t:0015-glossary-duplicate-term
  353. t:0015-glossary-location
  354. t:0015-glossary-location-global
  355. t:0016-metadata-reserved-underscores
  356. t:Generate Org ID stack
  357. t:Generate headings
  358. t:empty-input
  359. t:self-parse
  360. tbl:List markers
  361. tbl:Programming languages
  362. ti:0001-blankline
  363. ti:0001-comments-only
  364. ti:0002-doc-metadata-missing-value
  365. ti:0002-doc-metadata-properties-drawer
  366. ti:0002-doc-metadata-properties-drawer-clash
  367. ti:0002-doc-metadata-properties-drawer-combine
  368. ti:0002-doc-metadata-whitespace-before-value
  369. ti:0002-doc-metadata:basic
  370. ti:0003-doc-metadata-with-comments
  371. ti:0004-headings
  372. ti:0004-headings-20
  373. ti:0004-headings-cannot-skip-level
  374. ti:0004-headings-limited
  375. ti:0004-headings-prohibited
  376. ti:0004-headings-unlimited
  377. ti:0004-properties-drawer-empty
  378. ti:0004-properties-drawer-not-empty
  379. ti:0004-properties-drawer-not-empty-with-comments
  380. ti:0005-paragraph-indentation-mismatch-initial-indent-invalid
  381. ti:0005-paragraph-indentation-mismatch-initial-indent-invalid-between-levels
  382. ti:0005-paragraph-indentation-mismatch-over
  383. ti:0005-paragraph-indentation-mismatch-over-list-body
  384. ti:0005-paragraph-indentation-mismatch-under-list-body
  385. ti:0005-prose-paragraphs
  386. ti:0005-styled-strings-bold
  387. ti:0005-styled-strings-complex
  388. ti:0005-styled-strings-complex-punctuation
  389. ti:0005-styled-strings-compression
  390. ti:0005-styled-strings-escape-marker
  391. ti:0005-styled-strings-escaping
  392. ti:0005-styled-strings-leftover-active-styles
  393. ti:0005-styled-strings-messy-boundaries
  394. ti:0005-styled-strings-no-style
  395. ti:0005-styled-strings-slash-separated-words
  396. ti:0005-styled-strings-verbatim
  397. ti:0005-styled-strings-verbatim-code-ignore-immediate-bold
  398. ti:0005-styled-strings-verbatim-precedence
  399. ti:0006-links
  400. ti:0006-links-inside-parentheses
  401. ti:0006-links-nonlink
  402. ti:0006-links-oids
  403. ti:0006-links-oids-lenient-whitespace
  404. ti:0006-links-oids-line-labels
  405. ti:0006-links-raw
  406. ti:0006-links-web-protocols
  407. ti:0006-unique-line-labels
  408. ti:0007-block-src-without-lang
  409. ti:0007-blocks-duplicate-names
  410. ti:0007-blocks-duplicate-tangle-paths
  411. ti:0007-blocks-empty
  412. ti:0007-blocks-ending
  413. ti:0007-blocks-no-name-with-opening-parenthesis
  414. ti:0007-blocks-noweb
  415. ti:0007-blocks-noweb-intraline
  416. ti:0007-blocks-noweb-off
  417. ti:0007-blocks-special-commas
  418. ti:0008-indentation-comment
  419. ti:0008-indentation-headings
  420. ti:0008-indentation-paragraph
  421. ti:0009-list-content-simple
  422. ti:0009-list-indentation-nested
  423. ti:0009-list-indentation-nested-with-text
  424. ti:0009-list-indentation-no-indent
  425. ti:0009-list-indentation-proper-indent
  426. ti:0009-list-items-need-blank-line-separator
  427. ti:0009-list-nesting-max
  428. ti:0009-list-nesting-max-8
  429. ti:0009-lists-cannot-be-mixed-on-their-own
  430. ti:0009-lists-cannot-be-mixed-on-their-own-ordered
  431. ti:0009-lists-prohibited
  432. ti:0009-lists-require-precise-indentation
  433. ti:0009-ordered-list-head-cannot-use-letters
  434. ti:0009-ordered-list-head-needs-parenthesis
  435. ti:0009-ordered-lists-require-increment
  436. ti:0009-ordered-lists-start-from-1
  437. ti:0009-paragraph-can-close-deeper-levels
  438. ti:0009-paragraph-can-separate-mixed-lists
  439. ti:0010-inline-math
  440. ti:0010-standalone-math-fragments
  441. ti:0010-standalone-math-inside-lists
  442. ti:0011-figures-and-blocks
  443. ti:0012-non-marking-char
  444. ti:0012-tables-and-prose
  445. ti:0012-tables-inside-lists
  446. ti:0013-footnotes-broken-link
  447. ti:0013-footnotes-complex
  448. ti:0013-footnotes-multiple-groups
  449. ti:0013-footnotes-simple
  450. ti:0013-footnotes-unused-def
  451. ti:0014-citations-bibliography-location
  452. ti:0014-citations-csl-json-paths
  453. ti:0014-citations-csl-path
  454. ti:0014-citations-extra-semicolons-basic
  455. ti:0014-citations-extra-semicolons-consecutive
  456. ti:0014-citations-forbidden-in-headings
  457. ti:0014-citations-forbidden-in-verbatim-code
  458. ti:0014-citations-locators
  459. ti:0014-citations-locators-affixes
  460. ti:0014-citations-nested
  461. ti:0014-citations-single-cite
  462. ti:0014-citations-unbalanced-brackets-prefix-closing
  463. ti:0014-citations-unbalanced-brackets-prefix-opening
  464. ti:0014-citations-unbalanced-brackets-suffix-closing
  465. ti:0014-citations-unbalanced-brackets-suffix-opening
  466. ti:0014-citations-worg
  467. ti:0015-glossary-duplicate-term
  468. ti:0015-glossary-location
  469. ti:0015-glossary-location-global
  470. ti:0016-metadata-reserved-underscores
  471. type:OrgParser