Compile
1 Overview
Compilation is the intermediate step we perform on Org files, such that they can be tangled or woven. Org documents cannot be tangled or woven directly; instead they must first be compiled into objects, and then collected into an archive. We perform additional checks during each step so that we can tangle and weave reliably.
- object
- 🔗 An even more machine-friendly representation of a single Org document. Uses file extension *.lobj, and is encoded as JSON.
- archive
- 🔗 A self-contained collection of objects. All objects inside an archive can link to each other (headings, code blocks, etc) without any issues. Uses file extension *.larc, and is encoded as JSON.
We use JSON as the underlying format because it's easy to debug and is widely supported. In the future we could use another format for scalability or performance reasons, but JSON is good enough for now.
2 Lilac.Compile module
module Lilac.Compile
( cumulativeOffsets,
findCycles,
findExternalOids,
gciToName,
makeRelativeToObjectPath,
mkArchive,
mkGTOC,
mkObject,
parseCitationStyle,
readObjectsFrom,
)
where
import Citeproc qualified as CP
import Citeproc.CslJson (CslJson)
import Data.Aeson (eitherDecodeStrictText)
import Data.Graph qualified as G
import Data.IntMap.Strict (IntMap)
import Data.IntMap.Strict qualified as IntMap
import Data.List (partition)
import Data.Map.Strict qualified as M
import Data.Set qualified as Set
import Data.Text qualified as T
import Data.Tree
( Tree (Node),
unfoldTreeM,
)
import Lilac.Parse qualified as LP
import Lilac.Types
( Archive
( Archive,
ancestry,
backlinks,
bibs,
csls,
glossary,
gtoc,
objects,
offsets,
symbols
),
BToken (BTokenLinkTarget, BTokenPadding, BTokenString),
Block (body, meta, parentOid),
Cell
( CBlock,
CCommand,
CFigure,
CFootnote,
CGlossaryTerm,
CHeading,
CListItem,
CMathFragment,
CParagraph,
CTable
),
Figure (caption, link, meta, parentOid),
Footnote (body, name),
GCI (GCI, unGCI),
GPath,
GTOC (GTOC),
GlossaryTerm (definition, term),
Heading (body, meta),
LCI (LCI, unLCI),
Link (Link, target),
LinkOid (LinkOid, name, oid),
LinkTarget
( LinkTargetFilePath,
LinkTargetFootnote,
LinkTargetLineLabel,
LinkTargetOid,
LinkTargetURI
),
MathFragment (meta, parentOid),
OID,
Object (Object, externalOids, orgDoc, symbols),
OrgDoc (bibPaths, cells, cslPath, glossary, oid, pseudoEdges),
PToken
( PTokenCitation,
PTokenDivEnd,
PTokenDivStart,
PTokenLink,
PTokenMathFragment,
PTokenStyledText
),
ProjectConf (bibliography, citationStyle),
Prose (Prose, unProse),
PseudoEdge (PseudoEdge),
Style (Verbatim),
Table (caption, grid, meta, parentOid),
decodeGPath,
emptyGPath,
encodeGPath,
gciToObjCell,
getProseContents,
getSectionNumber,
ptext,
stripLinks,
)
import Lilac.Util qualified as LU
import Relude
( Bool,
Either (Left, Right),
FilePath,
IO,
Int,
Map,
Maybe (Just, Nothing),
State,
String,
Text,
Type,
abs,
catMaybes,
concat,
concatMap,
elem,
filter,
fmap,
foldl',
foldlM,
fromList,
fst,
get,
hashNub,
intercalate,
length,
map,
mapAccumL,
mapM,
mapMaybe,
maybeToList,
mempty,
modify,
not,
null,
otherwise,
pure,
repeat,
runState,
show,
snd,
sort,
unlines,
when,
zip,
(!!?),
($),
(+),
(-),
(.),
(/=),
(<),
(<$>),
(<>),
(=<<),
(==),
(>),
)
import Relude.Extra.Map (elems, insert, insertWith, lookup, member)
import System.Directory.OsPath qualified as D
import System.Exit (die)
import System.FilePath (splitDirectories)
import System.OsPath qualified as P
f:mkObject
f:mkSymbols
f:getLineLabels
f:getSymbols
f:mkArchive
f:combineSymbolsAndGlossary
f:docOidsToSymbols
f:mkOffsets
f:prependOffsets
f:findBrokenLinks
f:findBrokenFileLinks
f:getCitationDeps
f:finalizeEdges
f:mkBacklinks
f:readObjectsFrom
f:buildGraph
f:getBlocksFromArchive
f:getBlockGCIsFromArchive
f:mkBlockAncestry
f:findCycles
f:cumulativeOffsets
f:findExternalOids
data:PathState
f:mkGTOC
f:findRootIndex
f:isIndexObjPath
f:getChildPaths
f:getCitationStyles
f:getCitationStyle
f:parseCitationStyle
f:getBibliographies
f:getBibliography
f:parseBibliography
f:makeRelativeToObjectPath
f:gciToName3 Objects
The data:Object augments the data:OrgDoc with some additional metadata (derived from the OrgDoc).
The externalOids 2 lists all OID link targets which cannot be resolved by the symbols in this document. The expectation is that these external OIDs will be found in the data:Archive from other objects that have them.
The symbols 1 houses all links that point to cells inside the current OrgDoc. We use data:LinkOid as a key for convenience, and for this we need to define at least a ToJSONKey instance and FromJSONKey instance. Otherwise, Data.Aeson can give an unrelated error message like
Error in $.symbols: parsing Map failed, expected Array, but encountered Objectwhen trying to parse the JSON for symbols ("symbols":{}), such that "symbols":[] won't give an error (strange!).
3.1 JSON conversion
Marshaling to JSON is straightforward, because we can just reuse the instance:Show LinkOid, which already knows how to convert a LinkOid to a String value.
Unmarshaling is tricky because we have to inform Data.Aeson how to convert a Text to LinkOid. This is what we do in f:parseJSONLinkOid.
parseJSONLinkOid :: Text -> Parser LinkOid
parseJSONLinkOid t = do
oidAndNameText <- maybe (fail "invalid LinkOid") pure (T.stripPrefix "id:" t)
let oidText = T.take 36 oidAndNameText
maybeNameText = T.drop 36 oidAndNameText
name =
if T.length maybeNameText > 2
then T.drop 2 maybeNameText
else ""
case Lilac.Types.fromText oidText of
Nothing -> fail "invalid LinkOid"
Just oid -> pure $ LinkOid oid nameThis function is not very strict (it only implicitly checks for the :: separator between the UUID and the name (if any)), because it's good enough.
Now we're ready to define the boilerplate for doing the round trip to and back from JSON. The instance:ToJSON LinkOid converts the LinkOid to a JSON Value type (one of the constructors being a JSON string, or Data.Aeson.Types.String).
instance ToJSON LinkOid where
toJSON linkOid = Data.Aeson.Types.String (T.show linkOid)The instance:ToJSONKey LinkOid is simpler, because it just expects a type that can be a key. The use of toJSONKeyText (from Data.Aeson.Types) is important because it results in encoding the surrounding map as a JSON object {...} instead of an array of key-value pairs [...] (see the upstream docs here).
instance ToJSONKey LinkOid where
toJSONKey = toJSONKeyText showThe instances for both FromJSON and FromJSONKey rely on the f:parseJSONLinkOid we defined above.
instance FromJSON LinkOid where
parseJSON = withText "LinkOid" parseJSONLinkOidinstance FromJSONKey LinkOid where
fromJSONKey = FromJSONKeyTextParser parseJSONLinkOid3.2 Construction
f:mkObject creates an object out of a single data:OrgDoc.
f:mkSymbols 3 and f:findExternalOids 4 are discussed separately below.
3.2.1 Symbols
f:mkSymbols 3 generates a mapping of all data:LinkOid keys to their local cell index values 5 (also known as the newtype:LCI values). This represents the locations (cell indices) of where these symbols can be found.
mkSymbols :: OrgDoc -> Map LinkOid LCI
mkSymbols orgDoc =
fromList
. concatMap f
$ zip
(map LCI [0 ..]) -- 5
orgDoc.cells
where
f (lci, cell) = case cell of -- 6
CHeading heading
| Just oidText <- lookup "ID" heading.meta,
Just oid <- LP.validateOidText oidText ->
[ ( LinkOid
{ oid = oid,
name = T.empty
},
lci
)
]
| otherwise -> []
CBlock block ->
( getSymbols
orgDoc.oid
block.parentOid
block.meta
lci
)
<> (getLineLabels orgDoc.oid block.parentOid lci block) -- 7
CFigure figure ->
getSymbols
orgDoc.oid
figure.parentOid
figure.meta
lci
CMathFragment mathFragment ->
getSymbols
orgDoc.oid
mathFragment.parentOid
mathFragment.meta
lci
CTable table ->
getSymbols
orgDoc.oid
table.parentOid
table.meta
lci
CFootnote {}
CListItem {}
CParagraph {}
CGlossaryTerm {}
CCommand {} -> [] -- 8f:getLineLabels 7 associates a link to a line label with the code block cell that contains that line label. We use this information in f:weaveLink to look up the containing code block's name when the line label is contained in an external doc (because the name of the code block gives additional context).
getLineLabels :: OID -> OID -> LCI -> Block -> [(LinkOid, LCI)]
getLineLabels docOid parentOid lci block = foldl' f [] block.body
where
f acc bToken = case bToken of
(BTokenLinkTarget (LinkTargetLineLabel linkOid)) ->
(LinkOid docOid linkOid.name, lci)
: (LinkOid parentOid linkOid.name, lci)
: acc
_ -> accOnly headings, blocks, figures, math fragments, and tables can be linked to using a LinkOid. The other cell types are ignored 8.
We call the cell index the lci 6 for local cell index to differentiate it against the global cell index, which is used when handling archives, such as in f:gciToObjCell.
The f:getSymbols helper generates either one or two symbols for the same cell; these correspond to whether we use one OID (document OID) or two OIDs (document OID or the closest parent heading's OID) 9.
getSymbols :: OID -> OID -> Map Text Text -> LCI -> [(LinkOid, LCI)]
getSymbols docOid parentOid cellMeta lci =
let oids =
if parentOid /= docOid -- 9
then [parentOid, docOid]
else [docOid]
in mapMaybe extractName oids
where
extractName oid -- 10
| Just name <- lookup "name" cellMeta =
Just
( LinkOid
{ oid = oid,
name = name
},
lci
)
| Just tanglePath <- lookup "tangle" cellMeta -- 11
=
Just
( LinkOid
{ oid = oid,
name = tanglePath <> "\NUL"
},
lci
)
| Just anonBlockNumber <- lookup "__anon_block_number" cellMeta -- 12
=
Just
( LinkOid
{ oid = oid,
name = anonBlockNumber <> "\NUL__anon_block_number"
},
lci
)
| otherwise = NothingThe extractName function 10 tries to get some semblance of a name from the cell. Other than the name metadata, the tangle path 11 and the internal names of anonymous blocks 12 are used. We need 11 because some root blocks (that need to be tangled in f:expandRootBlocks) may be anonymous. Unless we include anonymous blocks as a symbol for these cases, they will be skipped over. And we need 12 because we need them for navigating a block's ancestry; specifically, a block may have an anonymous parent, and we still want to include such parents in f:getBlockGCIsFromArchive, which powers f:mkBlockAncestry.
Note that f:mkSymbols needs to return a Map LinkOid LCI. Because both 11 and 12 deal with anonymous blocks, their LinkOid values are constructed artificially. For both of them we inject a \NUL character so as to make it impossible to clash with a user-defined LinkOid (no sane user or text editor will let users mix in \NUL characters with other text). Avoiding clashes is important because the keys of a Map must be unique to avoid overwriting each other's values.
3.2.2 External OIDs
f:findExternalOids 4 looks for LinkTargetOid links that point to cells that are not inside the current Org file. We just look at all links and see if any of them point to an OID that is not accounted for in the symbols 16.
findExternalOids :: OrgDoc -> [LinkOid]
findExternalOids orgDoc =
sort
. Set.toList
. fromList
$ concatMap searchInsideProse orgDoc.cells
where
searchInsideProse cell = case cell of -- 13
CBlock block -> foldl' getLinkFromBToken [] block.body
CHeading heading -> foldl' getLinkFromPToken [] heading.body.unProse
CParagraph prose -> foldl' getLinkFromPToken [] prose.unProse
CGlossaryTerm glossaryTerm ->
foldl' getLinkFromPToken [] glossaryTerm.definition.unProse
CTable table ->
foldl'
(\acc prose -> foldl' getLinkFromPToken acc prose.unProse)
[]
table.grid
CFigure {}
CFootnote {}
CListItem {}
CMathFragment {}
CCommand {} -> []
getLinkFromBToken acc bToken = case bToken of -- 14
BTokenLinkTarget linkTarget -> keepIfExternal acc linkTarget
BTokenString {}
BTokenPadding -> acc
getLinkFromPToken acc pToken = case pToken of -- 15
PTokenLink link -> keepIfExternal acc link.target
PTokenStyledText {}
PTokenCitation {}
PTokenMathFragment {}
PTokenDivStart {}
PTokenDivEnd {} -> acc
symbols = mkSymbols orgDoc -- 16
keepIfExternal acc linkTarget = case linkTarget of -- 17
LinkTargetOid linkOid ->
if member linkOid symbols
then acc
else linkOid : acc
LinkTargetLineLabel linkOid ->
if member linkOid symbols
then acc
else linkOid : acc
LinkTargetFootnote {}
LinkTargetFilePath {}
LinkTargetURI {} -> accFirst we have to look at all areas where prose text can occur 13. The prose-like containers fall into two categories: data:BToken and data:PToken; these are handled by getLinkFromBToken 14 and getLinkFromPToken 15 respectively, which harvest the links out of them. Then we check if the link's target is external or not, only keeping it if it is external 17 (i.e., not found in symbols 16).
Although we call the helper keepIfExternal, internal links that point to OIDs within the document will still get treated as "external" if there is a typo in the name data:LinkOid → 75 part, because there won't be any local symbols for it. And then these links will get picked up as broken links in f:mkArchive.
Links to code blocks within the same document, whether explicitly defined with an OID or not, shouldn't result in external links.
:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000d000
:END:
#+name: c
#+begin_src haskell
c1
#+end_src
#+name: a
#+header: :tangle equations.txt
#+begin_src haskell
<<b>>
a1
<<b>>
#+end_src
#+name: b
#+begin_src haskell
b1
b2
<<id:00000000-0000-0000-0000-00000000d000::c>>
b3
#+end_srccheckExternalLinkOids
"0400-no-external-symbols-code-blocks"
[]Here is a more complex example, with internal links inside headings and paragraphs as well.
:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000d001
:END:
* h1 [[t:foo]]
#+name: t:foo
#+begin_src haskell
foo
#+end_src
Link to [[bar
baz]] and [[bar baz][long
description]]. Same link: [[id:00000000-0000-0000-0000-00000000d001::bar
baz]]. Another link [[id:00000000-0000-0000-0000-00000000d001::t:foo][here]].
#+name: bar baz
#+begin_src haskell
foo
<<t:foo>>
<<id:00000000-0000-0000-0000-00000000d001::t:foo>>
#+end_srccheckExternalLinkOids
"0400-no-external-symbols-complex"
[]Check for external links.
:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000d002
:END:
Hello [[id:ffffffff-0000-0000-0000-00000000d001::a]]checkExternalLinkOids
"0400-external-symbols"
[ LinkOid
{ oid = fromWords 0xffffffff00000000 0x000000000000d001,
name = "a"
}
]Here's a more complicated example with external links that break over multiple lines.
:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000d002
:END:
Hello [[id:ffffffff-0000-0000-0000-00000000d001::Hello
World]]. Another one: [[id:ffffffff-0000-0000-0000-00000000d001::a b c
d e][a b c d e]].checkExternalLinkOids
"0400-external-symbols-newlines"
[ LinkOid
{ oid = fromWords 0xffffffff00000000 0x000000000000d001,
name = "Hello World"
},
LinkOid
{ oid = fromWords 0xffffffff00000000 0x000000000000d001,
name = "a b c d e"
}
]4 Archives
A data:Archive is a collection of data:Object values. The objects field 18 holds this collection, where the newtype:GPath is the key of where that object was found. We have to use GPath because there is no ToJSONKey instance for P.OsPath (which would otherwise have been our first choice).
type Archive :: Type
data Archive = Archive
{ objects :: Map GPath Object, -- 18
symbols :: Map LinkOid GCI, -- 19
ancestry :: Map GCI [GCI], -- 20
glossary :: Map (Text, GPath) LCI, -- 21
backlinks :: Map GPath (Map GPath [(GCI, GCI)]), -- 22
offsets :: IntMap GPath, -- 23
gtoc :: GTOC, -- 24
csls :: Map FilePath Text, -- 25
bibs :: Map FilePath [CP.Reference (CslJson Text)] -- 26
}
deriving stock (Eq, Generic, Show)
deriving anyclass (FromJSON, ToJSON)Archives use a global cell index with newtype:GCI to distinguish these against a local cell index (newtype:LCI). This is required because there are multiple objects each with their own LCI, and we have to tell these apart from each other.
type GCI :: Type
newtype GCI = GCI
{ unGCI :: Int
}
deriving stock (Eq, Ord, Generic, Show)
deriving anyclass (FromJSON, FromJSONKey, Hashable, ToJSON, ToJSONKey)Archives also keep track of a global table of contents, called GTOC (for global Table of Contents) at 24. This is the global table of contents which puts every object found in the archive into a single hierarchy. So whereas a normal Page contents has entries that are headings in the current object, the GTOC has entries which are objects in the archive. So in a sense, the GTOC is basically the "sitemap" of all navigable HTML pages.
It uses a tree data structure to encode the name of an object, or the fact that there is a directory (with objects presumably inside it).
type GTOC :: Type
newtype GTOC = GTOC
{ unGTOC :: Tree GPath
}
deriving stock (Eq, Ord, Generic, Show)
deriving anyclass (FromJSON, FromJSONKey, Hashable, ToJSON, ToJSONKey)The other fields in data:Archive are based on the objects 18 inside it:
For example, if the object is the first one in the list, the offset is 0 for its cells. The offset grows depending on how many cells each object has. So if the first object has 100 cells, the offset for the second object in the list is 100. See f:cumulativeOffsets.
- symbols
- 🔗 19 A table for finding the cell by its GCI based on a given data:LinkOid. It could be the case that the user decides to link to the top-level UUID of the document data:OrgDoc → 1. This presents a problem because documents are not cells (they contain cells) so using a GCI doesn't really make sense. So instead we use negative numbers so as not to collide with the regular cell index numbers. See f:docOidsToSymbols.
- backlinks
- 🔗 22 A map where the keys are the paths (of objects) and the values are the backlinks to that object. We use a map because we want to organize all of the backlinks by the document they are starting out from. The actual backlinks are just edges between cells (vertices), where the edges are created by a reference (via data:LinkOid) from one cell to another. They are based on newtype:PseudoEdge which are constructed in f:oidLinkParser. See f:weaveBacklinks for how they are used during weaving.
- ancestry
- 🔗 20 A map of global cell indices of blocks to their parents (a block can have multiple parents because more than one block can refer to the same child block). This is created by f:mkBlockAncestry and later used by f:weaveBlockAncestry for making each block link back to its parents, if any.
- glossary
- 🔗 21 This is the combined set of all glossary terms found in all objects. The main difference is that the key includes not just the term itself (Text) but also the path of the object to identify which object has this term. This also avoids clashes because two documents could very well define the same glossary term (in different ways).
- csls
- 🔗 25 Collection of CSL paths and their raw XML values (see CSL style file (XML)). We have to store the information as a Text value, because there is no ToJSON instance for the Citeproc.Types.Style type. Typically csls will have at most a handful of entries, because most (if not all) documents inside an archive will all use the same CSL style. Needed for processing Citations and printing bibliographies. See Citation dependencies.
- bibs
- 🔗 26 Collection of all bibliography files used by documents. See CSL JSON (bibliographical entries). Needed for processing Citations and printing bibliographies. See Citation dependencies.
4.1 Construction
To construct the archive, f:mkArchive reads a list of path and object pairs and performs some processing on them to produce a data:Archive value. We need the path information mainly because ultimately when we weave these objects, we need to be able to understand the relative file path relationship between them in order to construct a relative HTML link, because the file structure layout of these objects will be preserved in the HTML file locations (by contrast, tangling does not involve tangling to relative paths).
mkArchive :: ProjectConf -> [(GPath, Object)] -> IO (Either String Archive)
mkArchive projectConf pathObjs = do
let pathObjsNonEmpty =
filter (\(_, obj) -> length obj.orgDoc.cells > 0) pathObjs -- 27
(symbols, glossary) = combineSymbolsAndGlossary pathObjsNonEmpty
offsets = mkOffsets pathObjsNonEmpty
backlinks = mkBacklinks pathObjsNonEmpty symbols offsets
brokenLinks = findBrokenLinks pathObjsNonEmpty symbols -- 28
brokenFileLinks <- findBrokenFileLinks pathObjs
(csls, bibs) <- getCitationDeps projectConf pathObjsNonEmpty
if
| brokenLinks /= [] ->
pure
. Left
$ "Found broken links:\n"
<> intercalate
"\n"
(map showBrokenLink brokenLinks)
| brokenFileLinks /= [] ->
pure
. Left
$ "Found broken file paths (from \"[[file:...]]\" links):\n"
<> intercalate
"\n"
(map showBrokenFileLink brokenFileLinks)
| otherwise ->
case mkGTOC pathObjs of
Left err -> pure $ Left err
Right gtoc -> do
let archive =
Archive
{ objects = fromList pathObjs,
symbols = symbols,
ancestry = mempty,
glossary = glossary,
backlinks = backlinks,
offsets = offsets,
gtoc = gtoc,
csls = csls,
bibs = bibs
}
ancestry =
mkBlockAncestry archive
$ getBlockGCIsFromArchive archive -- 29
pure $ Right archive {ancestry = ancestry}
where
showBrokenLink :: (GPath, LinkOid) -> String
showBrokenLink (gPath, link) =
" " <> (T.unpack $ show link) <> " (" <> (decodeGPath gPath) <> ")"
showBrokenFileLink :: (GPath, FilePath) -> String
showBrokenFileLink (gPath, filePath) =
" " <> (filePath) <> " (" <> (decodeGPath gPath) <> ")"The first thing we do is remove any empty objects that don't have any content 27, because they're meaningless and also probably introduce bugs into offset calculation in f:mkOffsets. Then the main thing is to see that there are no broken links within this collection of objects 28; see Broken link detection and f:findBrokenLinks.
Other than the checks, the other concern is to combine the different parts of an object together with other objects. Some parts of an object can be simply combined together with the same parts in another object (there's not much more processing to do). These are the object's symbols 1 and glossary data:OrgDoc → 6. This is done in f:combineSymbolsAndGlossary.
combineSymbolsAndGlossary ::
[(GPath, Object)] ->
(Map LinkOid GCI, Map (Text, GPath) LCI)
combineSymbolsAndGlossary pathObjs =
foldl' f (docOidsToSymbols pathObjs, mempty) $ prependOffsets pathObjs
where
f ::
(Map LinkOid GCI, Map (Text, GPath) LCI) ->
(Int, (GPath, Object)) ->
(Map LinkOid GCI, Map (Text, GPath) LCI)
f (accSyms, accGloss) (offset, (path, obj)) =
( (M.map (\lci -> GCI $ lci.unLCI + offset) obj.symbols) -- 30
<> accSyms,
(M.mapKeys (\term -> (term, path)) obj.orgDoc.glossary) <> accGloss
)LCI values of symbols are converted into GCI ones at 30, using offsets. These symbols come from cells (see f:mkSymbols).
We also generate (deterministically of course) a separate set of non-cell-based symbols for representing the documents (i.e., links to an Org document as a whole, not to any one of its constituent cells) in f:docOidsToSymbols. This can only be done at the archiving stage because an object by itself has no understanding of how many other objects there are in the archive, and hence, which index position it has relative to those other objects. These index positions are determined by sorting the objects by their path and using negative numbers so as not to clash with the positive GCI numbers from symbols targeting typical cell indices.
docOidsToSymbols :: [(GPath, Object)] -> Map LinkOid GCI
docOidsToSymbols pathObjs =
let topLevelLinkOids =
fromList
$ zip
(map (objToLinkOid . snd) $ sort pathObjs)
(map GCI [-1, -2 ..])
objToLinkOid :: Object -> LinkOid
objToLinkOid obj =
LinkOid
{ oid = obj.orgDoc.oid,
name = ""
}
in topLevelLinkOidsSeveral functions like f:combineSymbolsAndGlossary and f:mkBacklinks use f:prependOffsets to calculate offsets. This offset information is saved in compact form in f:mkOffsets. Let's first look at f:prependOffsets.
prependOffsets :: [(GPath, Object)] -> [(Int, (GPath, Object))]
prependOffsets pathObjs =
cumulativeOffsets
. map (\(path, obj) -> (length obj.orgDoc.cells, (path, obj)))
$ sort pathObjsThis just sums up the number of cells for each object, and then builds up cumulative sums. After this cumulative step in f:cumulativeOffsets, you can think of each offset as like a step in a staircase --- each offset is higher than the previous one. Indeed, f:cumulativeOffsets takes a list of offsets (each offset inside a tuple), and converts them so that the offsets are cumulative (building on each other).
cumulativeOffsets :: [(Int, a)] -> [(Int, a)]
cumulativeOffsets xs = snd (mapAccumL f 0 xs)
where
f :: Int -> (Int, a) -> (Int, (Int, a))
f currentSum (offset, x) =
let newSum = currentSum + offset
in (newSum, (currentSum, x))describe "cumulativeOffsets sanity check" $ do
it "works for empty case" $ do
let input = [] :: [(Int, String)]
output = [] :: [(Int, String)]
LC.cumulativeOffsets input `shouldBe` output
it "works for simple case" $ do
let input = [(3, "a"), (9, "b"), (1, "c"), (2, "d")] :: [(Int, String)]
output = [(0, "a"), (3, "b"), (12, "c"), (13, "d")] :: [(Int, String)]
LC.cumulativeOffsets input `shouldBe` outputf:mkOffsets just converts these cumulative offsets into a more compact IntMap form, so that we can make use of the very convenient IntMap.lookupLE function (such as in f:gciToObjCell).
mkOffsets :: [(GPath, Object)] -> IntMap GPath
mkOffsets pathObjs =
IntMap.fromList
. map (\(offset, (path, _obj)) -> (offset, path))
$ prependOffsets pathObjs4.2 Broken link detection
When dealing with multiple data:Object files at a time, we have to answer the question, "is this collection of objects able to resolve all internal data:LinkOid values"? Essentially this is the same question as asking if there are any broken links, as far as data:LinkOid targets are concerned.
The above question also has two different "flavors", depending on what we want to do:
For tangling, we care about broken links because block expansion in f:expandBlock depends on expanding all Noweb links with the contents of the code blocks they link to.
For weaving, we care about any broken link (data:LinkOid) to a cell, because otherwise we will not be able to generate the HTML link tag (<a>) with the correct href=... attribute for it.
In both cases, we obviously do not want any broken links, which we check with f:findBrokenLinks.
findBrokenLinks ::
[(GPath, Object)] ->
Map LinkOid GCI ->
[(GPath, LinkOid)]
findBrokenLinks pathObjs symbols =
foldl' getBrokenLink [] allExternalOids
where
getBrokenLink ::
[(GPath, LinkOid)] ->
(GPath, LinkOid) ->
[(GPath, LinkOid)]
getBrokenLink acc (gPath, linkOid) = case lookup linkOid symbols of
Just _ -> acc
Nothing -> (gPath, linkOid) : acc -- 31
allExternalOids :: [(GPath, LinkOid)]
allExternalOids =
foldl'
(\acc (gPath, obj) -> (zip (repeat gPath) obj.externalOids) <> acc)
[]
pathObjsThe key is to see if any of the links cannot be resolved by looking it up in the symbols map. If it's not there, then we mark it as broken 31.
4.2.1 Tests
Detect broken Noweb links.
:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000d003
:END:
#+name: c
#+begin_src haskell
c1
#+end_src
#+name: a
#+header: :tangle equations.txt
#+begin_src haskell
<<b>>
a1
<<x>>
#+end_src
#+name: b
#+begin_src haskell
b1
b2
<<id:00000000-0000-0000-0000-00000000d000::c>>
b3
#+end_srcexpectArchiveError
["0401-broken-links-noweb"]
"""
Found broken links:
id:00000000-0000-0000-0000-00000000d003::x (test/Lilac/CompileSpec/0401-broken-links-noweb)
id:00000000-0000-0000-0000-00000000d000::c (test/Lilac/CompileSpec/0401-broken-links-noweb)
"""Detect broken links in paragraphs. In this case, [[foo]] is not a named cell anywhere.
:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000d004
:END:
[[foo]]expectArchiveError
["0401-broken-links-paragraph"]
"""
Found broken links:
id:00000000-0000-0000-0000-00000000d004::foo (test/Lilac/CompileSpec/0401-broken-links-paragraph)
"""If we define the cell, the error goes away.
:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000d005
:END:
[[foo]]
#+name: foo
#+begin_src txt
Hello
#+end_srcexpectArchiveOk
["0401-broken-links-paragraph-ok"]Referring to a non-existent line label of the same name (modulo parentheses) as a named cell should result in an error.
:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000d004
:END:
#+name: foo
#+begin_src c
#+end_src
[[(foo)]]expectArchiveError
["0401-broken-links-line-label"]
"""
Found broken links:
id:00000000-0000-0000-0000-00000000d004::(foo) (test/Lilac/CompileSpec/0401-broken-links-line-label)
"""Below is similar to t:0401-broken-links-noweb, but the broken links have been fixed (we define the x cell and also the external document (dependency) that has cell c).
:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000d006
:END:
#+name: c
#+begin_src haskell
c1
#+end_src
#+name: a
#+header: :tangle equations.txt
#+begin_src haskell
<<b>>
a1
<<x>>
#+end_src
#+name: b
#+begin_src haskell
b1
b2
<<id:00000000-0000-0000-0000-00000000d007::c>>
b3
#+end_src
#+name: x
#+begin_src haskell
xxx
#+end_src:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000d007
:END:
#+name: c
#+begin_src haskell
This is c
#+end_srcexpectArchiveOk
[ "0401-broken-links-multi-ok",
"0401-broken-links-multi-ok-dependency"
]Ensure we allow links to an external cell, where the OID is either the top-level OID of the external Org doc, or the OID of the parent heading.
:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000d008
:END:
[[id:00000000-0000-0000-0000-00000000d009::foo]]
[[id:00000000-0000-0000-0000-00000000c000::foo]]:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000d009
:END:
* heading
:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000c000
:END:
#+name: foo
#+begin_src haskell
Hello from foo
#+end_srcexpectArchiveOk
[ "0401-broken-links-multi-ok-alternates-parent",
"0401-broken-links-multi-ok-alternates-child"
]4.3 Broken file link detection
Look at all file: links and see if they point to files that exist in the filesystem. This just looks at all links to check if a LinkTargetFilePath (data:LinkTarget → 74) points to a path that actually exists on disk.
We don't check for links inside code blocks, because code blocks cannot have file: links (f:bTokenParser does not construct any LinkTargetFilePath values).
findBrokenFileLinks ::
[(GPath, Object)] ->
IO [(GPath, FilePath)]
findBrokenFileLinks =
foldlM validateFilePath [] . allFilePaths
where
validateFilePath ::
[(GPath, FilePath)] ->
(GPath, FilePath) ->
IO [(GPath, FilePath)]
validateFilePath acc (objPath, filePath) = do
objOsPath <- P.encodeUtf $ decodeGPath objPath
let objDirOsPath = P.takeDirectory objOsPath
osPath <- P.encodeUtf filePath
exists <- D.doesPathExist $ P.combine objDirOsPath osPath
if exists
then pure acc
else pure $ (objPath, filePath) : acc
allFilePaths :: [(GPath, Object)] -> [(GPath, FilePath)]
allFilePaths pathObjs =
foldl'
( \acc (gPath, obj) ->
let result =
zip
(repeat gPath)
(concatMap extractFilesFromCell obj.orgDoc.cells)
in acc <> result
)
[]
pathObjs
extractFilesFromCell :: Cell -> [FilePath]
extractFilesFromCell = \case
CHeading heading -> extractFilesFromProse heading.body
CParagraph prose -> extractFilesFromProse prose
CGlossaryTerm glossaryTerm ->
concatMap
extractFilesFromProse
[ glossaryTerm.term,
glossaryTerm.definition
]
CFigure figure ->
extractFilesFromProse
figure.caption
<> (maybeToList $ extractFileFromLink figure.link)
CTable table ->
extractFilesFromProse
table.caption
<> (concatMap extractFilesFromProse $ elems table.grid)
CFootnote footnote -> extractFilesFromProse footnote.body
CBlock {}
CMathFragment {}
CListItem {}
CCommand {} -> []
extractFilesFromProse :: Prose -> [FilePath]
extractFilesFromProse prose = mapMaybe extractFileFromPToken prose.unProse
extractFileFromPToken :: PToken -> Maybe FilePath
extractFileFromPToken = \case
PTokenLink link -> extractFileFromLink link
_ -> Nothing
extractFileFromLink :: Link -> Maybe FilePath
extractFileFromLink (Link _ (LinkTargetFilePath filePath)) = Just filePath
extractFileFromLink _ = Nothing4.4 Cycle detection for Noweb links
All Noweb links (for code blocks) inside an Archive must be such that they do not form a cycle. This is because we have to expand (replace with the actual text) Noweb links during the tangling process, and we don't want to be in the position of expanding things forever.
We want to build up a graph using the Noweb link information embedded in each block. Using a graph data structure allows us to easily detect cycles, which are not allowed for tangling because they cannot be resolved.
You may think we could avoid using graphs entirely, and just use an algorithm like "keep expanding all Noweb links recursively; if we see a Noweb link being used more than once, error out" to detect cycles. However this won't work because you can technically reference the same block multiple times at different points in the tree. For example, the following code is valid:
#+begin_src txt
<<bar>>
foo
<<bar>>
#+end_srcWe want to build a directed graph where we treat all blocks as nodes (vertices) and the dependency of Noweb links as edges. So if a block x depends on a Noweb link y, then x would point to y.
From [1, Ch. 22] (p. 614) we can find a more formal definition of cycles in a directed graph:
A directed graph G is acyclic if and only if a depth-first search of G yields no
back edges.And a back edge is defined like this in [1, p. 609]:
Back edges are those edges (u, v) connecting a vertex u to an ancestor v in a
depth-first tree. We consider self-loops, which may occur in directed graphs, to
be back edges.Note that a back edge can be either an edge that points back to an ancestor, or a self-loop. Whether an edge points back to an ancestor can be determined by finding strongly connected components with more than one vertex. And we can check for self-loops by just seeing if a block links to itself.
A: circle "A"
B: circle "B" at A + (1.5, 0.5)
C: circle "C" at B + (1, 0)
D: circle "D" at B + (0.5, -0.9)
Self: spline -> from A.n go up 0.3 then right 0.4 then 0.2 heading 160 to A.ne
text "Self-loop" ljust at Self.w + (0.4, 0)
arrow from B.e to C.w
Mid: arrow <- from D.ne to C.s
arrow from D.nw to B.s
text "Strongly-connected" "component" at Mid + (0.7, -0.1)Checking for strongly connected components can be done with the scc function provided by the Data.Graph library. This library also lets us create a single graph with all of the blocks in it because the library doesn't care whether the edges we feed into it are connected (i.e., you can have as many disconnected "islands" of nodes as you want in the same graph).
The f:buildGraph function looks at all code blocks and generates the necessary edges to build up a directed graph. It does this by constructing edges that have the data:LinkOid of the starting block as one vertex, and the Noweb links found inside the body as the other vertices.
buildGraph ::
[(OID, Block)] -> -- 32
( G.Graph,
G.Vertex -> (Block, LinkOid, [LinkOid]),
LinkOid -> Maybe G.Vertex
)
buildGraph = G.graphFromEdges . foldl' f []
where
f ::
[(Block, LinkOid, [LinkOid])] ->
(OID, Block) ->
[(Block, LinkOid, [LinkOid])]
f acc (docOid, block)
| Just name <- lookup "name" block.meta =
let selfLinkByParentOid = LinkOid block.parentOid name
selfLinkByDocOid = LinkOid docOid name
addVertices outLinks =
if block.parentOid == docOid
then (block, selfLinkByDocOid, outLinks) : acc
else
(block, selfLinkByParentOid, outLinks) -- 33
: (block, selfLinkByDocOid, outLinks)
: acc
in addVertices . catMaybes $ map getOutLinks block.body
| otherwise = acc
getOutLinks = \case
BTokenLinkTarget (LinkTargetOid linkOid) -> Just linkOid
BTokenLinkTarget {}
BTokenPadding {}
BTokenString {} -> NothingThe input blocks are paired with the document OID that contains them 32. This is because we have to add two vertices for the starting block if its block.parentOid is different than the document's OID 33; that is, we add one vertex for the data:Block → 108 and another for the data:OrgDoc → 1. This is because of the problem of implicit Noweb links (Noweb links inside blocks that do not define an OID explicitly). When such links are parsed in f:oidReferenceParser, we treat them as using the document's OID f:oidReferenceParser → 80. So we obviously need to consider the document's OID here. However, we still also need to consider the block's parent OID when another block refers to this one explicitly by its parent OID. So in summary we have to consider both the implicit and explicit cases.
The f:findCycles function takes the output of f:buildGraph and uses that to run the scc function to get the strongly connected components, which we filter to only keep the ones that have cycles in them.
findCycles :: Archive -> [G.Tree LinkOid]
findCycles archive = map (fmap toLinkOid) . foldl' f [] $ G.scc graph
where
(graph, vertexToNode, _) = buildGraph $ getBlocksFromArchive archive
toLinkOid vertex =
let (_, selfLink, _) = vertexToNode vertex
in selfLink
f acc tree@(G.Node vertex _) -- 34
| length tree > 1 = tree : acc
| (_, selfLink, outLinks) <- vertexToNode vertex,
elem selfLink outLinks =
tree : acc
| otherwise = accChecking for back edges means just seeing whether there is more than one vertex, or if there are any self-loops 34.
Lastly we have f:getBlocksFromArchive. Note that we pair up each block with the OID of the containing document; this OID is used in f:buildGraph when adding vertices. f:getBlocksFromArchive also saves the document OID with each block to keep track of which document that block came from.
getBlocksFromArchive :: Archive -> [(OID, Block)]
getBlocksFromArchive archive = foldl' f [] $ elems archive.symbols
where
f :: [(OID, Block)] -> GCI -> [(OID, Block)]
f acc gci
| Just (obj, cell) <- gciToObjCell archive gci =
case cell of
CBlock b -> (obj.orgDoc.oid, b) : acc
CHeading {}
CListItem {}
CParagraph {}
CGlossaryTerm {}
CMathFragment {}
CFigure {}
CTable {}
CFootnote {}
CCommand {} -> acc
| otherwise = acc4.4.1 Tests
We use the f:checkCycles helper (powered by f:findCycles underneath) to detect cycles (or lack thereof).
First let's check for the happy path where there are no cycles. Note the use of :noweb off in the block's metadata so as to preserve the Noweb link declaration (see f:nowebLinkTargetParser and t:0007-blocks-noweb for more sample usage of :noweb off).
:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000e000
:END:
#+name: f:a
#+begin_src haskell
f = 1 + 1
<<f:b>>
#+end_src
#+name: f:b
#+begin_src haskell
g = 2 + 2
#+end_srccheckCycles
["0100-dag-no-loops"]
[]Now check for the simplest case of a single self-loop.
:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000e001
:END:
#+name: f:a
#+begin_src haskell
f = 1 + 1
<<f:a>>
#+end_srccheckCycles
["0100-dag-self-loop"]
[ Node
{ rootLabel =
LinkOid
{ oid = fromWords 0x0000000000000000 0x000000000000e001,
name = "f:a"
},
subForest = []
}
]Below is the same, but for the case where there is an intervening heading.
:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000e001
:END:
* heading
:PROPERTIES:
:ID: 11111111-1111-1111-1111-111111111111
:END:
#+name: f:a
#+begin_src haskell
f = 1 + 1
<<f:a>>
#+end_srccheckCycles
["0100-dag-self-loop-heading"]
[ Node
{ rootLabel =
LinkOid
{ oid = fromWords 0x0000000000000000 0x000000000000e001,
name = "f:a"
},
subForest = []
},
Node
{ rootLabel =
LinkOid
{ oid = fromWords 0x0000000000000000 0x000000000000e001,
name = "f:a"
},
subForest = []
}
]We get two nodes (vertices) in t:0100-dag-self-loop-heading instead of just one in ti:0100-dag-self-loop because when we add the two vertices for this case in f:buildGraph, both vertices will link out to the same document OID (implicitly) as an out link when they are parsed in at f:oidReferenceParser → 80. So from the cycle detection algorithm it's as if we linked from the code block (vertex) in the test to another code block that sits above the heading, which them links to itself forming a 2-block cycle.
Check whether we can detect two independent self-loops.
:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000e003
:END:
#+name: f:a
#+begin_src haskell
f = 1 + 1
<<f:a>>
#+end_src
#+name: f:b
#+begin_src haskell
f = 1 + 1
<<f:b>>
#+end_srccheckCycles
["0100-dag-self-loops"]
[ Node
{ rootLabel =
LinkOid
{ oid = fromWords 0x0000000000000000 0x000000000000e003,
name = "f:a"
},
subForest = []
},
Node
{ rootLabel =
LinkOid
{ oid = fromWords 0x0000000000000000 0x000000000000e003,
name = "f:b"
},
subForest = []
}
]t:0100-dag-2-node-loop checks whether we can detect a back edge to an ancestor by a cycle involving 2 nodes.
:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000e004
:END:
#+name: f:a
#+begin_src haskell
f = 1 + 1
<<f:b>>
#+end_src
#+name: f:b
#+begin_src haskell
f = 1 + 1
<<f:a>>
#+end_srccheckCycles
["0100-dag-2-node-loop"]
[ Node
{ rootLabel =
LinkOid
{ oid = fromWords 0x0000000000000000 0x000000000000e004,
name = "f:a"
},
subForest =
[ Node
{ rootLabel =
LinkOid
{ oid = fromWords 0x0000000000000000 0x000000000000e004,
name = "f:b"
},
subForest = []
}
]
}
]The test below is like ti:0100-dag-2-node-loop but with an intervening heading.
:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000e004
:END:
#+name: f:a
#+begin_src haskell
f = 1 + 1
<<f:b>>
#+end_src
* heading
:PROPERTIES:
:ID: 11111111-1111-1111-1111-111111111111
:END:
#+name: f:b
#+begin_src haskell
f = 1 + 1
<<f:a>>
#+end_srccheckCycles
["0100-dag-2-node-loop-heading"]
[ Node
{ rootLabel =
LinkOid
{ oid = fromWords 0x0000000000000000 0x000000000000e004,
name = "f:a"
},
subForest =
[ Node
{ rootLabel =
LinkOid
{ oid = fromWords 0x0000000000000000 0x000000000000e004,
name = "f:b"
},
subForest = []
}
]
}
]Test case t:0100-dag-3-node-loop is like the previous one, but where the loop is one node longer (involves 3 nodes instead of 2).
:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000e005
:END:
#+name: f:a
#+begin_src haskell
f = 1 + 1
<<f:b>>
#+end_src
#+name: f:b
#+begin_src haskell
f = 1 + 1
<<f:c>>
#+end_src
#+name: f:c
#+begin_src haskell
f = 1 + 1
<<f:a>>
#+end_srccheckCycles
["0100-dag-3-node-loop"]
[ Node
{ rootLabel =
LinkOid
{ oid = fromWords 0x0000000000000000 0x000000000000e005,
name = "f:a"
},
subForest =
[ Node
{ rootLabel =
LinkOid
{ oid = fromWords 0x0000000000000000 0x000000000000e005,
name = "f:b"
},
subForest =
[ Node
{ rootLabel =
LinkOid
{ oid =
fromWords
0x0000000000000000
0x000000000000e005,
name = "f:c"
},
subForest = []
}
]
}
]
}
]Lastly, we have a test case involving complex loops. There are many back edges involved here; we just want to make sure that our algorithm for finding cycles is still able to handle such pathological input.
:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000e006
:END:
#+name: f:a
#+begin_src haskell
f = 1 + 1
<<f:b>>
#+end_src
#+name: f:b
#+begin_src haskell
f = 1 + 1
<<f:c>>
<<f:d>>
#+end_src
#+name: f:c
#+begin_src haskell
f = 1 + 1
<<f:a>>
#+end_src
#+name: f:d
#+begin_src haskell
f = 1 + 1
<<f:b>>
<<f:d>>
#+end_src
#+name: f:e
#+begin_src haskell
f = 1 + 1
<<f:e>>
#+end_srccheckCycles
["0100-dag-complex-loops"]
[ Node
{ rootLabel =
LinkOid
{ oid = fromWords 0x0000000000000000 0x000000000000e006,
name = "f:a"
},
subForest =
[ Node
{ rootLabel =
LinkOid
{ oid = fromWords 0x0000000000000000 0x000000000000e006,
name = "f:b"
},
subForest =
[ Node
{ rootLabel =
LinkOid
{ oid =
fromWords
0x0000000000000000
0x000000000000e006,
name = "f:c"
},
subForest = []
},
Node
{ rootLabel =
LinkOid
{ oid =
fromWords
0x0000000000000000
0x000000000000e006,
name = "f:d"
},
subForest = []
}
]
}
]
},
Node
{ rootLabel =
LinkOid
{ oid = fromWords 0x0000000000000000 0x000000000000e006,
name = "f:e"
},
subForest = []
}
]Cycle detection should still work even for cycles that span across two files.
:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000f004
:END:
#+name: a
#+header: :tangle equations.txt
#+begin_src haskell
hello from a
<<id:00000000-0000-0000-0000-00000000f005::b>>
#+end_src:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000f005
:END:
#+name: b
#+begin_src haskell
hello from b
<<id:00000000-0000-0000-0000-00000000f004::a>>
#+end_srccheckCycles
[ "0100-dag-2-node-loop-2-files-parent",
"0100-dag-2-node-loop-2-files-child"
]
[ Node
{ rootLabel =
LinkOid
{ oid = fromWords 0x0000000000000000 0x000000000000f004,
name = "a"
},
subForest =
[ Node
{ rootLabel =
LinkOid
{ oid = fromWords 0x0000000000000000 0x000000000000f005,
name = "b"
},
subForest = []
}
]
}
]Below is the same test, but involving an intervening heading (only for the parent).
:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000f004
:END:
* heading
:PROPERTIES:
:ID: 11111111-1111-1111-1111-111111111111
:END:
#+name: a
#+header: :tangle equations.txt
#+begin_src haskell
hello from a
<<id:00000000-0000-0000-0000-00000000f005::b>>
#+end_srccheckCycles
[ "0100-dag-2-node-loop-2-files-parent-heading",
"0100-dag-2-node-loop-2-files-child"
]
[ Node
{ rootLabel =
LinkOid
{ oid = fromWords 0x0000000000000000 0x000000000000f004,
name = "a"
},
subForest =
[ Node
{ rootLabel =
LinkOid
{ oid = fromWords 0x0000000000000000 0x000000000000f005,
name = "b"
},
subForest = []
}
]
}
]If three files form a cycle, that should also be detected.
:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000f006
:END:
#+name: a
#+header: :tangle equations.txt
#+begin_src haskell
hello from a
<<id:00000000-0000-0000-0000-00000000f007::b>>
#+end_src:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000f007
:END:
#+name: b
#+begin_src haskell
hello from b
<<id:00000000-0000-0000-0000-00000000f008::c>>
#+end_src:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000f008
:END:
#+name: c
#+begin_src haskell
hello from c
<<id:00000000-0000-0000-0000-00000000f006::a>>
#+end_srccheckCycles
[ "0100-dag-2-node-loop-3-files-parent",
"0100-dag-2-node-loop-3-files-middleman",
"0100-dag-2-node-loop-3-files-child"
]
[ Node
{ rootLabel =
LinkOid
{ oid = fromWords 0x0000000000000000 0x000000000000f006,
name = "a"
},
subForest =
[ Node
{ rootLabel =
LinkOid
{ oid = fromWords 0x0000000000000000 0x000000000000f007,
name = "b"
},
subForest =
[ Node
{ rootLabel =
LinkOid
{ oid =
fromWords
0x0000000000000000
0x000000000000f008,
name = "c"
},
subForest = []
}
]
}
]
}
]4.5 Block ancestry
f:mkBlockAncestry builds a "graph" similar to the one used in f:findCycles, but with a few differences:
The graph's edges are reversed, so that each block points to its parent(s) instead of pointing to its children.
It uses a Map instead of a Graph, because we don't care about relationships between more than 2 vertices at a time (and also there's no need to check for cycles, like we already did in f:findCycles).
It uses global cell indices instead of data:LinkOid values, because an anonymous block can refer to a named block (that is, it is impossible to create a LinkOid for an anonymous block so we cannot use LinkOid values here).
This information is used in f:weaveBlockAncestry to generate ancestry information. This can be helpful to know where exactly a block is referenced by a Noweb link.
mkBlockAncestry :: Archive -> [GCI] -> Map GCI [GCI]
mkBlockAncestry archive blockGCIs = foldl' f mempty blockGCIs
where
f :: Map GCI [GCI] -> GCI -> Map GCI [GCI]
f acc blockGCI
| Just (_, CBlock block) <- gciToObjCell archive blockGCI =
( \childLinks ->
foldl'
( \acc' childLink ->
case lookup childLink archive.symbols of
Just childGCI ->
insertWith
(\old new -> hashNub (old <> new))
childGCI
[blockGCI]
acc'
Nothing -> acc'
)
acc
childLinks
)
. catMaybes
$ map getChildLinks block.body
| otherwise = acc
getChildLinks = \case
BTokenLinkTarget (LinkTargetOid linkOid) -> Just linkOid
BTokenLinkTarget {}
BTokenPadding {}
BTokenString {} -> Nothingf:getBlockGCIsFromArchive gets all code blocks (by their newtype:GCI) from the archive. This is used for calling f:mkBlockAncestry 29.
getBlockGCIsFromArchive :: Archive -> [GCI]
getBlockGCIsFromArchive archive = foldl' f [] $ elems archive.symbols
where
f :: [GCI] -> GCI -> [GCI]
f acc gci
| Just (_, cell) <- gciToObjCell archive gci =
case cell of
CBlock {} -> gci : acc
CHeading {}
CListItem {}
CParagraph {}
CGlossaryTerm {}
CMathFragment {}
CFigure {}
CTable {}
CFootnote {}
CCommand {} -> acc
| otherwise = accThe line elems archive.symbols just gets all code blocks from the symbols field of the data:Archive (perhaps elems is a poor name and should have been called values instead, but that's probably because "values" from "keys and values" shadows the "values" in "types vs values").
4.6 Backlinks
f:mkBacklinks takes the edges which contain global cell index information for both the starting and ending vertices, and then transforms them into the final shape Map GPath (Map GPath [(GCI, GCI)]):
Map GPath (Map GPath [(GCI, GCI)])
| | |
| | `---> List of edges (source GCI, target GCI)
| `---> Source object path
`----> Target object pathThis shape is largely driven by the needs of f:weaveBacklinks.
mkBacklinks ::
[(GPath, Object)] ->
Map LinkOid GCI ->
IntMap GPath ->
Map GPath (Map GPath [(GCI, GCI)])
mkBacklinks pathObjs symbols offsets =
M.map (M.fromListWith (<>)) $ foldl' f mempty symbolEdges
where
symbolEdges :: [(GCI, GCI)]
symbolEdges =
foldl'
(finalizeEdges symbols) -- 35
[]
$ prependOffsets pathObjs
sortedPaths :: [GPath]
sortedPaths = sort $ map fst pathObjs
f acc (from, to)
| Just (_, fromPath) <- IntMap.lookupLE from.unGCI offsets,
Just (_, toPath) <- IntMap.lookupLE to.unGCI offsets,
fromPath /= toPath =
M.insertWith (<>) toPath [(fromPath, [(from, to)])] acc
| Just (_, fromPath) <- IntMap.lookupLE from.unGCI offsets,
to.unGCI < 0,
Just toPath <- sortedPaths !!? (abs to.unGCI - 1),
fromPath /= toPath =
M.insertWith (<>) toPath [(fromPath, [(from, to)])] acc
| otherwise = accf:finalizeEdges 35 looks at each object's pseudoEdges and converts them into real edges (using GCI as the vertex type) using the global symbols map (Map LinkOid GCI) and offsets. Each PseudoEdge links from the object's LCI (without any offsets) into a LinkOid target, so we need to perform two transformations:
Convert the LinkOid target into a global cell index, by looking it up in the global symbols (Map LinkOid GCI) 36.
Convert the LCI into a GCI, by adding the offset to it 37.
finalizeEdges ::
Map LinkOid GCI ->
[(GCI, GCI)] ->
(Int, (GPath, Object)) ->
[(GCI, GCI)]
finalizeEdges symbols acc (offset, (_, obj)) =
let f (PseudoEdge (lci, linkOid)) = case lookup linkOid symbols of -- 36
Nothing -> Nothing
Just targetGCI -> Just (GCI $ lci.unLCI + offset, targetGCI) -- 37
in (catMaybes $ map f obj.orgDoc.pseudoEdges) <> acc4.7 Citation dependencies
f:getCitationDeps reads CSL files and also bibliographies (JSON files). This is the main workhorse, as the other functions in this section are just helpers to f:getCitationDeps.
getCitationDeps ::
ProjectConf ->
[(GPath, Object)] ->
IO (Map FilePath Text, Map FilePath [CP.Reference (CslJson Text)])
getCitationDeps projectConf pathObjs = do
csls <- getCitationStyles projectConf pathObjs
bibs <- getBibliographies projectConf pathObjs
pure (csls, bibs)We'll first look at citations, and then bibliographies.
4.7.1 Read citation style (XML) files
f:getCitationStyles relies on f:getCitationStyle which returns both the raw XML content as well as the parsed content. We discard the latter with a map fst 39. We can only keep the raw XML (as a Text) because we want to store it in f:mkArchive, and there is no ToJSON instance for the CP.Style type.
getCitationStyles :: ProjectConf -> [(GPath, Object)] -> IO (Map FilePath Text)
getCitationStyles projectConf pathObjs = do
allCslPaths <-
concat
<$> mapM
( \(gPath, obj) ->
makeRelativeToObjectPath gPath -- 38
$ maybeToList obj.orgDoc.cslPath
)
pathObjs
cslRawXMLs <- map fst <$> mapM getCitationStyle allCslPaths -- 39
let m = fromList $ zip allCslPaths cslRawXMLs
case projectConf.citationStyle of
Just cslPath -> do
(text, _) <- getCitationStyle cslPath
pure $ insert cslPath text m -- 40
Nothing -> do
pure mThe returned type is a Map FilePath Text where the key is the path to the CSL file, and the Text is the contents of it. During weaving in f:prepareCitations, we do a lookup into this Map.
40 inserts the default CSL path, if any, into the response. This is used as a fallback in f:prepareCitations if an Org doc does not specify a CSL style.
f:makeRelativeToObjectPath 38 gets a [FilePath] and makes them relative to the given GPath (by just combining the GPath with the file paths). This is useful for getting paths defined inside an Org doc (such as the path to a CSL style or bibliography JSON) to be relative to where the Org doc is located.
makeRelativeToObjectPath :: GPath -> [FilePath] -> IO [FilePath]
makeRelativeToObjectPath objGPath fps = do
let objPath = decodeGPath objGPath
objOsp <- P.encodeUtf objPath
let objDir = P.takeDirectory objOsp
mapM (f objDir) fps
where
f :: P.OsPath -> FilePath -> IO FilePath
f objDir fp = do
p <- P.encodeUtf fp
let relPath = P.combine objDir p
P.decodeUtf relPathFor parsing the CSL style file (XML), we read a given path, then defer to f:parseCitationStyle. Even though we discard this parsed content in f:getCitationStyles, we still want to parse it here because it's important validation (to know that the underlying XML is valid).
getCitationStyle :: FilePath -> IO (Text, (CP.Style (CslJson Text)))
getCitationStyle filePath = do
p <- P.encodeUtf filePath
input <- LU.readFileRobustly p
case parseCitationStyle input of
Left errMsg -> die $ "while parsing " <> filePath <> ":" <> errMsg
Right style -> pure (input, style)f:parseCitationStyle just delegates most of the work to parseStyle from Citeproc.Style. We expect the contents to be in CslJson Text format.
parseCitationStyle :: Text -> Either String (CP.Style (CslJson Text))
parseCitationStyle input = do
result <- CP.parseStyle (\_ -> pure mempty) input
case result of
Left citeprocError -> Left $ show citeprocError
Right style -> Right style4.7.2 Read bibliography (JSON) files
The f:getBibliographies reads multiple JSON files that have bibliographical entries in them.
getBibliographies ::
ProjectConf ->
[(GPath, Object)] ->
IO (Map FilePath [CP.Reference (CslJson Text)])
getBibliographies projectConf pathObjs = do
bibPaths <-
concat
<$> mapM
(\(gPath, obj) -> makeRelativeToObjectPath gPath obj.orgDoc.bibPaths)
pathObjs
let allBibPaths = projectConf.bibliography <> bibPaths
fromList <$> mapM (\p -> fmap (\b -> (p, b)) (getBibliography p)) allBibPathsThe f:getBibliography parses a single JSON file which has a list of references inside it.
getBibliography :: FilePath -> IO [CP.Reference (CslJson Text)]
getBibliography filePath = do
p <- P.encodeUtf filePath
input <- LU.readFileRobustly p
case parseBibliography input of
Left errMsg -> die $ "while parsing " <> filePath <> ":" <> errMsg
Right references -> pure referencesf:parseBibliography just delegates to eitherDecodeStrictText from Data.Aeson. This is possible because the Reference type (from Citeproc.Types) has a FromJSON instance.
parseBibliography :: Text -> Either String [CP.Reference (CslJson Text)]
parseBibliography = eitherDecodeStrictText4.8 GTOC
GTOC construction needs to find all index.org objects, and the special index code block inside each one. We have to then construct a single large tree out of this, where the values in the leaf nodes are GPath strings of objects in the archive.
Below are some type signatures with some small modifications to better understand where we want to go.
unfoldTree 41 is the basic function which knows how to grow a tree, starting from a seed value b and a generator function which can grow the next level of a tree. 42 is the same signature but with more descriptive type names. Finally 43 shows the signature with the types we'll be using, with m representing a monadic context; we use the State monad at 46 so that we can record invalid paths as we start building up the tree. Invalid paths are stored in data:PathState.
As for the details around growing the tree itself, it is as follows:
Find the root index.org object (along with its GPath).
Find the index code block and get a list of all paths.
Convert each path in the list with a new (synthetic) GPath.
Replace each GPath by its object, by doing a lookup in the map of all objects. The map is visible from the State monad so that we can read from it at any time without having to pass it along to every child call.
If the path does not exist in the objMap 44, then record it inside 45.
The first step is done in f:findRootIndex, which is called by f:mkGTOC. The remaining steps are carried out in f:getChildPaths.
mkGTOC :: [(GPath, Object)] -> Either String GTOC
mkGTOC pathObjs
| Just pathObj <- findRootIndex pathObjs =
let objMap = fromList pathObjs
invalidPaths = [] :: [GPath]
initialState = PathState objMap invalidPaths
(gtoc, finalState) = runState (f pathObj) initialState
in if null finalState.invalidPaths
then Right gtoc
else
let paths =
map
((" " <>) . T.pack . decodeGPath)
finalState.invalidPaths
in Left
$ "Invalid GTOC object paths:\n"
<> (T.unpack $ unlines paths)
| otherwise = Right . GTOC $ Node emptyGPath []
where
f :: (GPath, Object) -> State PathState GTOC
f pathObj = GTOC <$> unfoldTreeM generator pathObj -- 46
generator ::
(GPath, Object) ->
State PathState (GPath, [(GPath, Object)])
generator (path, obj)
| isIndexObjPath path = do
childPaths <- getChildPaths (path, obj)
pure (path, childPaths)
| otherwise = pure (path, [])f:isIndexObjPath just checks if the file ends in a index.lobj file path.
isIndexObjPath :: GPath -> Bool
isIndexObjPath gpath = "index.lobj" `T.isSuffixOf` (T.pack $ decodeGPath gpath)f:findRootIndex retrieves the root index.org object, if there is one. It finds the index.org path which has the fewest number of leading parent directories. This assumes that there is only one index.org file inside any directory.
findRootIndex :: [(GPath, Object)] -> Maybe (GPath, Object)
findRootIndex pathObjs =
let result = foldl' f Nothing pathObjs
in snd <$> result
where
f ::
Maybe (Int, (GPath, Object)) ->
(GPath, Object) ->
Maybe (Int, (GPath, Object))
f acc (curGPath, obj) =
let pathStr = decodeGPath curGPath
in if isIndexObjPath curGPath
then
let curDepth = length $ splitDirectories pathStr
in case acc of
Nothing -> Just (curDepth, (curGPath, obj))
Just (shallowestDepth, _)
| curDepth < shallowestDepth ->
Just (curDepth, (curGPath, obj))
| otherwise -> acc
else accf:getChildPaths gets all the paths inside an index code block. These paths are "incomplete" in a way, because they only hold the name of the directory or file at the current level. So we have to complete the path by merging it with the path information of the parent object.
getChildPaths :: (GPath, Object) -> State PathState [(GPath, Object)]
getChildPaths (path, obj) = foldlM f [] obj.orgDoc.cells
where
f :: [(GPath, Object)] -> Cell -> State PathState [(GPath, Object)]
f acc = \case
CBlock b
| Just "index" <- lookup "name" b.meta -> do
let rawText = T.concat $ map extractBTokenStringOnly b.body
indexPath = T.pack $ decodeGPath path -- 47
startingPath = T.dropEnd 10 indexPath -- 48
rawChildPaths = T.lines rawText
isIndexPath :: Text -> Bool
isIndexPath = (== "index.org") -- 49
(indexPaths, standardPaths) = partition isIndexPath rawChildPaths
toGPath' = mapMaybe (toGPath startingPath)
paths = toGPath' standardPaths -- 50
invalidIndexPaths = toGPath' indexPaths
when (not $ null invalidIndexPaths) $ do
modify
$ \s ->
s
{ invalidPaths = s.invalidPaths <> invalidIndexPaths
}
results <- mapM lookupSingle paths
pure $ acc <> catMaybes results
_ -> pure acc
toGPath :: Text -> Text -> Maybe GPath
toGPath startingPath textPath =
let joinedPath = startingPath <> textPath -- 51
constructedPath =
-- 52
if ".org" `T.isSuffixOf` joinedPath
then (T.dropEnd 3 joinedPath) <> "lobj"
else joinedPath <> "/index.lobj"
in encodeGPath $ T.unpack constructedPath
lookupSingle :: GPath -> State PathState (Maybe (GPath, Object))
lookupSingle p = do
st <- get
case lookup p st.objMap of
Just o -> pure $ Just (p, o)
Nothing -> do
modify $ \s -> s {invalidPaths = s.invalidPaths <> [p]}
pure Nothing
extractBTokenStringOnly :: BToken -> Text
extractBTokenStringOnly = \case
BTokenString text -> text
_ -> ""The sequence of operations (with the state of the path at each step) is below:
Start with the indexPath 47 (e.g., dir/index.lobj).
Drop the last 10 chars 48 to get the parent dir, dir/.
Add the textPath 51 to get dir/foo.org.
52 If ending in .org, replace with .lobj. Otherwise this is a directory, so add it and then also add a trailing /index.lobj to it.
50 avoids recursing into another index.org document, because that will lead to infinite recursion. The check at 49 doesn't take into account any directory slashes, because child paths may only refer to direct children within the same directory.
4.8.1 Tests
Don't error out if there's no index.lobj --- GTOC generation is optional.
:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000f010
:END:
fooexpectGTOC
["0105/foo.org"]
(Right . GTOC $ Node emptyGPath [])Root index.lobj exists, but without an index code block.
:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000f011
:END:
This is an index file.expectGTOC
[ "0105/foo.org",
"0105/index.org"
]
(Right . GTOC $ Node (GPath "test/Lilac/CompileSpec/0105/index.lobj") [])Root index.lobj exists, with a basic index code block.
:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000f012
:END:
foo:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000f013
:END:
foo:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000f014
:END:
This is an index file.
#+name: index
#+begin_src txt
foo.org
bar.org
#+end_srcexpectGTOC
[ "0105/basic/foo.org",
"0105/basic/bar.org",
"0105/basic/index.org"
]
( Right
. GTOC
$ Node
(GPath "test/Lilac/CompileSpec/0105/basic/index.lobj")
[ Node (GPath "test/Lilac/CompileSpec/0105/basic/foo.lobj") [],
Node (GPath "test/Lilac/CompileSpec/0105/basic/bar.lobj") []
]
)Invalid paths specified in the index code block, but which do not exist in the Archive's list of object paths should show up as an error. In the code block, we specify a directory named dir1 and an Org file README.org, but neither are present in the given list of paths.
:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000f014
:END:
This is an index file.
#+name: index
#+begin_src txt
dir1
README.org
#+end_srcexpectGTOC
[ "0105/invalid-paths/index.org"
]
( Left
"""
Invalid GTOC object paths:
test/Lilac/CompileSpec/0105/invalid-paths/dir1/index.lobj
test/Lilac/CompileSpec/0105/invalid-paths/README.lobj
"""
)Listing index.org in the list of child paths is invalid.
:PROPERTIES:
:ID: 00000000-0000-0000-0000-00000000f014
:END:
This is an index file.
#+name: index
#+begin_src txt
index.org
dir1
README.org
#+end_srcexpectGTOC
[ "0105/invalid-paths-self/index.org"
]
( Left
"""
Invalid GTOC object paths:
test/Lilac/CompileSpec/0105/invalid-paths-self/index.lobj
test/Lilac/CompileSpec/0105/invalid-paths-self/dir1/index.lobj
test/Lilac/CompileSpec/0105/invalid-paths-self/README.lobj
"""
)5 Utility functions
f:gciToName gives a human-friendly name of the cell. Much of it is patterned after f:getLinkAnchor, which gives a semi-human-friendly (and definitely machine-friendly) name for a given cell. We use the pilcrow (¶) and section symbol (§) directly for paragraphs and headings because f:weaveStyledText will escape non-HTML-safe characters with HTML entities, such that if we do ¶ the & will get escaped to &, which would result in the broken &para;.
gciToName :: Archive -> Int -> GCI -> Maybe Prose
gciToName archive offset gci
| Just (_, cell) <- gciToObjCell archive gci =
case cell of
CHeading heading ->
plain
$ "§ "
<> (getSectionNumber heading)
<> " "
<> (getProseContents heading.body)
CParagraph _ ->
Just
$ Prose
[ ptext "¶ (" [],
ptext cellRef [Verbatim],
ptext ")" []
]
CGlossaryTerm glossaryTerm ->
Just
$ Prose [ptext "✿ " []]
<> stripLinks glossaryTerm.term -- 53
CFigure figure
| Just name <- lookup "name" figure.meta -> verbatim name
| Just figureNum <- lookup "__figure_number" figure.meta ->
plain $ "Figure " <> figureNum
| otherwise -> plain "Figure"
CTable table
| Just name <- lookup "name" table.meta -> verbatim name
| Just tableNum <- lookup "__table_number" table.meta ->
plain $ "Table " <> tableNum
| otherwise -> plain "Table"
CFootnote footnote ->
Just
$ Prose
[ ptext "Footnote (" [],
ptext ("fn:" <> footnote.name) [Verbatim],
ptext ")" []
]
CBlock block
| Just name <- lookup "name" block.meta ->
verbatim name
| otherwise -> plain $ "Anonymous Block" <> cellRef
CMathFragment mathFragment
| Just name <- lookup "name" mathFragment.meta ->
verbatim name
| Just eqNum <- lookup "__equation_number" mathFragment.meta ->
plain $ "Equation " <> eqNum
| Just eqNum <- lookup "__equation_anon_number" mathFragment.meta ->
plain $ "Anonymous Equation " <> eqNum
| otherwise -> plain "Math Fragment"
CListItem {}
CCommand {} -> Nothing
| otherwise = Nothing
where
cellRef = "Cell " <> (T.show $ gci.unGCI - offset)
plain body = Just $ Prose [ptext body []]
verbatim body = Just $ Prose [ptext body [Verbatim]]We have to convert links (PTokenLink) into styled text (PTokenStyledText) at 53 because otherwise when we create backlinks at f:weaveBacklinks, we can potentially end up with nested <a> tags (because backlink generation means always wrapping the name of a backlink with a link (<a> tag) to that backlink), which would be illegal HTML (HTML does not allow nesting of <a> tags). f:stripLinks just takes the display name of any link and turns that into styled text.
stripLinks :: Prose -> Prose
stripLinks (Prose pTokens) = Prose (concatMap stripLink pTokens)
where
stripLink :: PToken -> [PToken]
stripLink = \case
PTokenLink link -> map (PTokenStyledText $) link.name
pToken -> [pToken]f:stripStyles removes all styling masks.
stripStyles :: Prose -> Prose
stripStyles (Prose pTokens) = Prose (concatMap stripStyle pTokens)
where
stripStyle :: PToken -> [PToken]
stripStyle = \case
PTokenStyledText styledText -> [ptext styledText.body []]
pToken -> [pToken]f:getCellName is similar to f:gciToName, but it only returns a Just value if the cell is a block, figure, table, footnote, or math fragment that has a #+name: ....
getCellName :: Cell -> Maybe Prose
getCellName = \case
CFigure figure
| Just name <- lookup "name" figure.meta -> verbatim name
| otherwise -> Nothing
CTable table
| Just name <- lookup "name" table.meta -> verbatim name
| otherwise -> Nothing
CFootnote footnote ->
Just
$ Prose
[ ptext "Footnote (" [],
ptext ("fn:" <> footnote.name) [Verbatim],
ptext ")" []
]
CBlock block
| Just name <- lookup "name" block.meta ->
verbatim name
| otherwise -> Nothing
CMathFragment mathFragment
| Just name <- lookup "name" mathFragment.meta ->
verbatim name
| otherwise -> Nothing
CHeading {}
CParagraph {}
CGlossaryTerm {}
CListItem {}
CCommand {} -> Nothing
where
verbatim body = Just $ Prose [ptext body [Verbatim]]f:gciToObjCell takes a global cell index and converts it to the right cell and the object (Org doc) that contains it, as a tuple.
gciToObjCell :: Archive -> GCI -> Maybe (Object, Cell)
gciToObjCell archive gci
| Just (offset, path) <- IntMap.lookupLE gci.unGCI archive.offsets,
Just obj <- lookup path archive.objects,
Just cell <- obj.orgDoc.cells !!? (gci.unGCI - offset) =
Just (obj, cell)
| otherwise = Nothingf:gciToObjPath gets the path of the object by the negative GCI index; it is in this sense the opposite of f:docOidsToSymbols.
gciToObjPath :: Archive -> GCI -> Maybe GPath
gciToObjPath archive gci
| gci.unGCI < 0 =
(sort $ keys archive.objects) !!? (abs gci.unGCI - 1)
| Just (_, path) <- IntMap.lookupLE gci.unGCI archive.offsets =
Just path
| otherwise = Nothing6 Testing framework
The testing framework here is modeled closely after module:Lilac.ParseSpec.
6.1 Test module and helpers
module Lilac.CompileSpec (spec) where
import Data.Graph qualified as G
import Data.Text qualified as T
import Data.Tree
( Tree (Node, rootLabel, subForest),
)
import Lilac.Compile qualified as LC
import Lilac.Parse qualified as LP
import Lilac.Types
( GTOC (GTOC),
LinkOid (LinkOid, name, oid),
Object,
decodeGPath,
emptyGPath,
emptyProjectConf,
encodeGPath,
fromWords,
)
import Lilac.Types.Internal (GPath (GPath))
import Lilac.Util qualified as LU
import Relude
( Either (Left, Right),
FilePath,
Int,
Maybe (Just, Nothing),
MonadFail,
String,
fail,
isRight,
map,
pure,
show,
traverse,
($),
(.),
(<>),
)
import Test.Hspec
( Spec,
describe,
expectationFailure,
it,
runIO,
shouldBe,
shouldSatisfy,
)
spec :: Spec
spec = do
All test cases
CompileSpec helpersThe test cases in All test cases will be defined with the following test helpers.
Note that the helpers use the more convenient FilePath type instead of P.OsPath, mainly because using the QuasiQuoter for P.OsPath slows down the compilation process and because data:Archive uses GPath anyway (and not P.OsPath).
f:checkExternalLinkOids checks if we can detect which symbols (named by their LinkOid) cannot be accounted for in the current object, and are hence external.
checkExternalLinkOids :: FilePath -> [LinkOid] -> Spec
checkExternalLinkOids path expectedExternalOids = do
it path $ do
(Right od) <- read
LC.findExternalOids od `shouldBe` expectedExternalOids
where
read = do
input <- LU.readFrom "test/Lilac/CompileSpec/" path
pure $ LP.parseOrgDoc inputf:readObjectsFrom reads files from a given parent directory, and converts them into data:Object values. This is used in tests where we want to read multiple objects for a test case.
readObjectsFrom :: FilePath -> [FilePath] -> IO [(GPath, Object)]
readObjectsFrom parentDir = foldlM f []
where
f :: [(GPath, Object)] -> FilePath -> IO [(GPath, Object)]
f acc path = do
parentDirEncoded <- P.encodeUtf parentDir
p <- P.encodeUtf path
let relPath = P.combine parentDirEncoded p
input <- LU.readFileRobustly relPath
case LP.parseOrgDoc input of
Left errTxt -> die $ T.unpack errTxt
Right od -> do
relPath' <- encodeGPath =<< P.decodeUtf relPath
pure $ (relPath', mkObject od) : accWe want to check broken links for data:Archive values as well. For this we have f:expectArchiveError.
expectArchiveError :: [FilePath] -> String -> Spec
expectArchiveError paths expected = do
it (show paths) $ do
pathObjs <- LC.readObjectsFrom "test/Lilac/CompileSpec/" paths
maybeArchive <- LC.mkArchive emptyProjectConf pathObjs
maybeArchive `shouldBe` (Left expected)The other side of the coin is f:expectArchiveOk which expects the archive to be successfully created.
expectArchiveOk :: [FilePath] -> Spec
expectArchiveOk paths = do
it (show paths) $ do
pathObjs <- LC.readObjectsFrom "test/Lilac/CompileSpec/" paths
maybeArchive <- LC.mkArchive emptyProjectConf pathObjs
maybeArchive `shouldSatisfy` isRightThe f:checkCycles function uses f:findCycles to see if the archive that is constructed from the given Org documents has any cycles.
checkCycles :: [FilePath] -> [G.Tree LinkOid] -> Spec
checkCycles paths expectedCycles = do
pathObjs <- runIO $ LC.readObjectsFrom "test/Lilac/CompileSpec/" paths
maybeArchive <- runIO $ LC.mkArchive emptyProjectConf pathObjs
case maybeArchive of
Left errMsg -> it "programmer error" $ expectationFailure errMsg
Right archive -> it (show paths) $ do
(LC.findCycles archive) `shouldBe` expectedCyclesf:expectGTOC looks at file paths and checks the result of running f:mkGTOC against them.
Normally, Lilac users would compile the Org files into objects, then feed those objects into f:mkArchive which would result in the .lobj filename extensions. However for testing purposes we take a shortcut and perform this .lobj conversion by simply replacing .org with .lobj. This business with extensions matters because f:mkGTOC expects file paths to end in .lobj.
expectGTOC :: [FilePath] -> Either String GTOC -> Spec
expectGTOC paths expected = do
it (show paths) $ do
pathObjs <- LC.readObjectsFrom "test/Lilac/CompileSpec/" paths
pathObjs' <- orgToLobj pathObjs
LC.mkGTOC pathObjs' `shouldBe` expected
where
orgToLobj ::
(MonadFail m) =>
[(GPath, Object)] ->
m [(GPath, Object)]
orgToLobj pairs = do
traverse reencode transformed
where
transformed :: [(T.Text, Object)]
transformed = map transformPath pairs
transformPath :: (GPath, Object) -> (T.Text, Object)
transformPath (gp, obj) =
let path = T.pack $ decodeGPath gp
newPath =
if ".org" `T.isSuffixOf` path
then (T.dropEnd 4 path) <> ".lobj"
else path
in (newPath, obj)
reencode :: (MonadFail m) => (T.Text, Object) -> m (GPath, Object)
reencode (t, obj) =
case encodeGPath (T.unpack t) of
Just newGp -> pure (newGp, obj)
Nothing ->
fail
$ "Failed to re-encode path: "
<> T.unpack t6.2 All test cases
Below are all of the individual test cases.
t:0400-no-external-symbols-code-blocks
t:0400-no-external-symbols-complex
t:0400-external-symbols
t:0400-external-symbols-newlines
t:0401-broken-links-noweb
t:0401-broken-links-paragraph
t:0401-broken-links-paragraph-ok
t:0401-broken-links-line-label
t:0401-broken-links-multi-ok
t:0401-broken-links-multi-ok-alternates
t:0100-dag-no-loops
t:0100-dag-self-loop
t:0100-dag-self-loop-heading
t:0100-dag-self-loops
t:0100-dag-2-node-loop
t:0100-dag-2-node-loop-heading
t:0100-dag-3-node-loop
t:0100-dag-complex-loops
t:0100-dag-2-node-loop-2-files
t:0100-dag-2-node-loop-2-files-heading
t:0100-dag-2-node-loop-3-files
t:0105-gtoc-no-root-index
t:0105-gtoc-root-index-without-index-block
t:0105-gtoc-basic
t:0105-gtoc-invalid-paths
t:0105-gtoc-invalid-paths-self
t:0500-cumulativeOffsets-sanity7 Glossary
- ancestry
- ↑ 20 A map of global cell indices of blocks to their parents (a block can have multiple parents because more than one block can refer to the same child block). This is created by f:mkBlockAncestry and later used by f:weaveBlockAncestry for making each block link back to its parents, if any.
- archive
- ↑ A self-contained collection of objects. All objects inside an archive can link to each other (headings, code blocks, etc) without any issues. Uses file extension *.larc, and is encoded as JSON.
- backlinks
- ↑ 22 A map where the keys are the paths (of objects) and the values are the backlinks to that object. We use a map because we want to organize all of the backlinks by the document they are starting out from. The actual backlinks are just edges between cells (vertices), where the edges are created by a reference (via data:LinkOid) from one cell to another. They are based on newtype:PseudoEdge which are constructed in f:oidLinkParser. See f:weaveBacklinks for how they are used during weaving.
- bibs
- ↑ 26 Collection of all bibliography files used by documents. See CSL JSON (bibliographical entries). Needed for processing Citations and printing bibliographies. See Citation dependencies.
- csls
- ↑ 25 Collection of CSL paths and their raw XML values (see CSL style file (XML)). We have to store the information as a Text value, because there is no ToJSON instance for the Citeproc.Types.Style type. Typically csls will have at most a handful of entries, because most (if not all) documents inside an archive will all use the same CSL style. Needed for processing Citations and printing bibliographies. See Citation dependencies.
- glossary
- ↑ 21 This is the combined set of all glossary terms found in all objects. The main difference is that the key includes not just the term itself (Text) but also the path of the object to identify which object has this term. This also avoids clashes because two documents could very well define the same glossary term (in different ways).
- object
- ↑ An even more machine-friendly representation of a single Org document. Uses file extension *.lobj, and is encoded as JSON.
- offsets
- ↑ 23 The offset of each object's cells, in relation to the global (flattened) list of all cells in the archive. This is used to convert a GCI back into an LCI (so that we can look up the actual cell at this index inside the object).
- symbols
- ↑ 19 A table for finding the cell by its GCI based on a given data:LinkOid. It could be the case that the user decides to link to the top-level UUID of the document data:OrgDoc → 1. This presents a problem because documents are not cells (they contain cells) so using a GCI doesn't really make sense. So instead we use negative numbers so as not to collide with the regular cell index numbers. See f:docOidsToSymbols.
8 Bibliography
Backlinks (33)
Tangled files (36)
- image/cycles.pikchr
- src/Lilac/Compile.hs
- test/Lilac/CompileSpec.hs
- test/Lilac/CompileSpec/0100-dag-2-node-loop
- test/Lilac/CompileSpec/0100-dag-2-node-loop-2-files-child
- test/Lilac/CompileSpec/0100-dag-2-node-loop-2-files-parent
- test/Lilac/CompileSpec/0100-dag-2-node-loop-2-files-parent-heading
- test/Lilac/CompileSpec/0100-dag-2-node-loop-3-files-child
- test/Lilac/CompileSpec/0100-dag-2-node-loop-3-files-middleman
- test/Lilac/CompileSpec/0100-dag-2-node-loop-3-files-parent
- test/Lilac/CompileSpec/0100-dag-2-node-loop-heading
- test/Lilac/CompileSpec/0100-dag-3-node-loop
- test/Lilac/CompileSpec/0100-dag-complex-loops
- test/Lilac/CompileSpec/0100-dag-no-loops
- test/Lilac/CompileSpec/0100-dag-self-loop
- test/Lilac/CompileSpec/0100-dag-self-loop-heading
- test/Lilac/CompileSpec/0100-dag-self-loops
- test/Lilac/CompileSpec/0105/basic/bar.org
- test/Lilac/CompileSpec/0105/basic/foo.org
- test/Lilac/CompileSpec/0105/basic/index.org
- test/Lilac/CompileSpec/0105/foo.org
- test/Lilac/CompileSpec/0105/index.org
- test/Lilac/CompileSpec/0105/invalid-paths-self/index.org
- test/Lilac/CompileSpec/0105/invalid-paths/index.org
- test/Lilac/CompileSpec/0400-external-symbols
- test/Lilac/CompileSpec/0400-external-symbols-newlines
- test/Lilac/CompileSpec/0400-no-external-symbols-code-blocks
- test/Lilac/CompileSpec/0400-no-external-symbols-complex
- test/Lilac/CompileSpec/0401-broken-links-line-label
- test/Lilac/CompileSpec/0401-broken-links-multi-ok
- test/Lilac/CompileSpec/0401-broken-links-multi-ok-alternates-child
- test/Lilac/CompileSpec/0401-broken-links-multi-ok-alternates-parent
- test/Lilac/CompileSpec/0401-broken-links-multi-ok-dependency
- test/Lilac/CompileSpec/0401-broken-links-noweb
- test/Lilac/CompileSpec/0401-broken-links-paragraph
- test/Lilac/CompileSpec/0401-broken-links-paragraph-ok
Named cells (120)
- All test cases
- CompileSpec helpers
- data:Archive
- data:Object
- data:PathState
- f:buildGraph
- f:checkCycles
- f:checkExternalLinkOids
- f:combineSymbolsAndGlossary
- f:cumulativeOffsets
- f:docOidsToSymbols
- f:expectArchiveError
- f:expectArchiveOk
- f:expectGTOC
- f:finalizeEdges
- f:findBrokenFileLinks
- f:findBrokenLinks
- f:findCycles
- f:findExternalOids
- f:findRootIndex
- f:gciToName
- f:gciToObjCell
- f:gciToObjPath
- f:getBibliographies
- f:getBibliography
- f:getBlockGCIsFromArchive
- f:getBlocksFromArchive
- f:getCellName
- f:getChildPaths
- f:getCitationDeps
- f:getCitationStyle
- f:getCitationStyles
- f:getLineLabels
- f:getSymbols
- f:isIndexObjPath
- f:makeRelativeToObjectPath
- f:mkArchive
- f:mkBacklinks
- f:mkBlockAncestry
- f:mkGTOC
- f:mkObject
- f:mkOffsets
- f:mkSymbols
- f:parseBibliography
- f:parseCitationStyle
- f:parseJSONLinkOid
- f:prependOffsets
- f:readObjectsFrom
- f:stripLinks
- f:stripStyles
- fig:cycles
- fig:cycles.pikchr
- instance:FromJSON LinkOid
- instance:FromJSONKey LinkOid
- instance:ToJSON LinkOid
- instance:ToJSONKey LinkOid
- module:Lilac.Compile
- module:Lilac.CompileSpec
- newtype:GCI
- newtype:GTOC
- t:0100-dag-2-node-loop
- t:0100-dag-2-node-loop-2-files
- t:0100-dag-2-node-loop-2-files-heading
- t:0100-dag-2-node-loop-3-files
- t:0100-dag-2-node-loop-heading
- t:0100-dag-3-node-loop
- t:0100-dag-complex-loops
- t:0100-dag-no-loops
- t:0100-dag-self-loop
- t:0100-dag-self-loop-heading
- t:0100-dag-self-loops
- t:0105-gtoc-basic
- t:0105-gtoc-invalid-paths
- t:0105-gtoc-invalid-paths-self
- t:0105-gtoc-no-root-index
- t:0105-gtoc-root-index-without-index-block
- t:0400-external-symbols
- t:0400-external-symbols-newlines
- t:0400-no-external-symbols-code-blocks
- t:0400-no-external-symbols-complex
- t:0401-broken-links-line-label
- t:0401-broken-links-multi-ok
- t:0401-broken-links-multi-ok-alternates
- t:0401-broken-links-noweb
- t:0401-broken-links-paragraph
- t:0401-broken-links-paragraph-ok
- t:0500-cumulativeOffsets-sanity
- ti:0100-dag-2-node-loop
- ti:0100-dag-2-node-loop-2-files-child
- ti:0100-dag-2-node-loop-2-files-parent
- ti:0100-dag-2-node-loop-2-files-parent-heading
- ti:0100-dag-2-node-loop-3-files-child
- ti:0100-dag-2-node-loop-3-files-middleman
- ti:0100-dag-2-node-loop-3-files-parent
- ti:0100-dag-2-node-loop-heading
- ti:0100-dag-3-node-loop
- ti:0100-dag-complex-loops
- ti:0100-dag-no-loops
- ti:0100-dag-self-loop
- ti:0100-dag-self-loop-heading
- ti:0100-dag-self-loops
- ti:0105-gtoc-basic-bar
- ti:0105-gtoc-basic-foo
- ti:0105-gtoc-basic-index
- ti:0105-gtoc-invalid-paths
- ti:0105-gtoc-invalid-paths-self
- ti:0105-gtoc-no-root-index
- ti:0105-gtoc-root-index-without-index-block
- ti:0400-external-symbols
- ti:0400-external-symbols-newlines
- ti:0400-no-external-symbols-code-blocks
- ti:0400-no-external-symbols-complex
- ti:0401-broken-links-line-label
- ti:0401-broken-links-multi-ok
- ti:0401-broken-links-multi-ok-alternates-child
- ti:0401-broken-links-multi-ok-alternates-parent
- ti:0401-broken-links-multi-ok-dependency
- ti:0401-broken-links-noweb
- ti:0401-broken-links-paragraph
- ti:0401-broken-links-paragraph-ok