//
// Copyright (c) 2024, Brian Frank and Andy Frank
// Licensed under the Academic Free License version 3.0
//
// History:
// 08 Oct 2024 Matthew Giannini Creation
//
**
** Block parsing state.
**
@Js
mixin ParserState
{
** The current source line being parsed (full line)
abstract SourceLine? line()
** The current index within the line (0-based)
abstract Int index()
** Get the index of the next non-space character starting from `index`
** (may be the same) (0-based)
abstract Int nextNonSpaceIndex()
** The colum is the position within the line after tab characters have been
** processed as 4-space tab stops. If the line doesn't contain any tabs, it's
** the same as the `index`. If the line starts with a tab, followed by text, then
** the column for the first character of the text is 4 (the index is 1)
**
** Returns the current column within the line (0-based)
abstract Int column()
** Get the indentation in columns (either by spaces or tab stop of 4), starting
** from `column`.
abstract Int indent()
** Return true if the current line is blank starting from the `index`
abstract Bool isBlank()
** Get the deepest open block parser
abstract BlockParser activeBlockParser()
}
**
** Parses text into a `Document` AST
**
@Js
internal class DocumentParser : ParserState
{
** Core block types
static const Type[] core_block_types := [
BlockQuote#,
Heading#,
FencedCode#,
HtmlBlock#,
ThematicBreak#,
ListBlock#,
IndentedCode#,
]
static const [Type:BlockParserFactory] core_factories
static
{
acc := [Type:BlockParserFactory][:] { ordered = true }
acc[BlockQuote#] = BlockQuoteParser.factory
acc[Heading#] = HeadingParser.factory
acc[FencedCode#] = FencedCodeParser.factory
acc[HtmlBlock#] = HtmlBlockParser.factory
acc[ThematicBreak#] = ThematicBreakParser.factory
acc[ListBlock#] = ListBlockParser.factory
acc[IndentedCode#] = IndentedCodeParser.factory
DocumentParser.core_factories = acc.toImmutable
}
//////////////////////////////////////////////////////////////////////////
// Constructor
//////////////////////////////////////////////////////////////////////////
new make(Parser parser)
{
this.parser = parser
this.blockParserFactories = parser.blockParserFactories
this.includeSourceSpans = parser.includeSourceSpans
// initialize the document block parser
this.documentBlockParser = DocumentBlockParser()
activateBlockParser(OpenBlockParser(documentBlockParser, 0))
}
private DocumentBlockParser documentBlockParser
private Definitions definitions := Definitions()
//////////////////////////////////////////////////////////////////////////
// Config
//////////////////////////////////////////////////////////////////////////
const Parser parser
const BlockParserFactory[] blockParserFactories
const IncludeSourceSpans includeSourceSpans
static BlockParserFactory[] calculateBlockParserFactories(BlockParserFactory[] custom, Type[] enabled)
{
acc := BlockParserFactory[,]
// by having the custom factories come first, extensions are able to change behavior
// of core syntax!
acc.addAll(custom)
enabled.each |type| { acc.add(core_factories[type]) }
return acc
}
static Void checkEnabledBlockTypes(Type[] types)
{
types.each |type|
{
if (core_factories[type] == null)
throw ArgErr("Can't enable block type ${type}, possible options are ${core_block_types}")
}
}
//////////////////////////////////////////////////////////////////////////
// State
//////////////////////////////////////////////////////////////////////////
override SourceLine? line := null { private set }
** Current line index (0-based)
private Int lineIndex := -1
override Int index := 0 { private set }
override Int column := 0 { private set }
** Is the current column within a tab character (partially consumed tab)
private Bool columnIsInTab := false
override Int nextNonSpaceIndex := 0 { private set } // nextNonSpace
private Int nextNonSpaceColumn := 0
override Int indent := 0 { private set }
override Bool isBlank := false { private set }
override BlockParser activeBlockParser()
{
openBlockParsers.last.blockParser
}
** Block parsers that are currently in the "open" state
private OpenBlockParser[] openBlockParsers := [,]
** All block parsers
private BlockParser[] allBlockParsers := [,]
//////////////////////////////////////////////////////////////////////////
// Parse
//////////////////////////////////////////////////////////////////////////
** Parse the input into a document AST
Document parse(InStream in)
{
Str? line := null
while ((line = in.readLine) != null)
parseLine(line)
return finalizeAndProcess
}
** Inspect the current line and update the document accordingly.
private Void parseLine(Str ln)
{
setLine(ln)
// For each containing block, try to parse the associated line start.
// The document will always match, so we can skip the first block parser
// and start at 1 matches
matches := 1
for (i := 1; i < openBlockParsers.size; ++i)
{
openBlockParser := openBlockParsers[i]
blockParser := openBlockParser.blockParser
findNextNonSpace
blockContinue := blockParser.tryContinue(this)
// we found the first block parser that could *not*
// continue parsing from the current state
if (blockContinue == null) break
// this open block parser matched
openBlockParser.sourceIndex = this.index
if (blockContinue.finalize)
{
addSourceSpans
closeBlockParsers(openBlockParsers.size - i)
return
}
else
{
if (blockContinue.newIndex != -1) setNewIndex(blockContinue.newIndex)
else if (blockContinue.newColumn != -1) setNewColumn(blockContinue.newColumn)
++matches
}
}
unmatchedBlocks := openBlockParsers.size - matches
blockParser := openBlockParsers[matches-1].blockParser
startedNewBlock := false
lastIndex := this.index
// unless last matched container is a code block, try new container starts,
// adding children to the last matched container:
tryBlockStarts := blockParser.block is Paragraph || blockParser.isContainer
while (tryBlockStarts)
{
lastIndex = this.index
findNextNonSpace
// this is a little performance optimization
if (isBlank || (indent < Parsing.code_block_indent && Chars.isLetter(this.line.content, nextNonSpaceIndex)))
{
setNewIndex(nextNonSpaceIndex)
break
}
blockStart := findBlockStart(blockParser)
if (blockStart == null)
{
setNewIndex(nextNonSpaceIndex)
break
}
startedNewBlock = true
sourceIndex := this.index
// We're starting a new block. If we have any previous blocks that need to
// be closed, we need to do it now.
if (unmatchedBlocks > 0)
{
closeBlockParsers(unmatchedBlocks)
unmatchedBlocks = 0
}
if (blockStart.newIndex != -1)
setNewIndex(blockStart.newIndex)
else if (blockStart.newColumn != -1)
setNewColumn(blockStart.newColumn)
SourceSpan[]? replacedSourceSpans := null
if (blockStart.isReplaceActiveBlockParser)
{
replacedBlock := prepareActiveBlockParserForReplacement
replacedSourceSpans = replacedBlock.sourceSpans
}
blockStart.blockParsers.each |newBlockParser|
{
addChild(OpenBlockParser(newBlockParser, sourceIndex))
if (replacedSourceSpans != null)
newBlockParser.block.setSourceSpans(replacedSourceSpans)
blockParser = newBlockParser
tryBlockStarts = newBlockParser.isContainer
}
}
// What remains at the offset is a text line.
// Add the text to the appropriate block.
// First check for a lazy continuation line
if (!startedNewBlock && !isBlank && activeBlockParser.canHaveLazyContinuationLines)
{
// lazy paragraph continuation
openBlockParsers.last.sourceIndex = lastIndex
addLine
}
else
{
// finalize any blocks not matched
if (unmatchedBlocks > 0)
closeBlockParsers(unmatchedBlocks)
if (!blockParser.isContainer)
{
addLine
}
else if (!isBlank)
{
// create paragraph container for line
paragraphParser := ParagraphParser()
addChild(OpenBlockParser(paragraphParser, lastIndex))
addLine
}
else
{
// This can happen for a list item like this:
// ```
// *
// list item
// ```
//
// The first line does not start a paragraph yet, but we still want to
// record the source position
addSourceSpans
}
}
}
** Update document parser state for a new line of input
private Void setLine(Str ln)
{
// move to next line
this.lineIndex++
this.index = 0
this.column = 0
this.columnIsInTab = false
// set current source line (with optional source span)
lineContent := prepareLine(ln)
SourceSpan? sourceSpan := null
if (includeSourceSpans != IncludeSourceSpans.none)
sourceSpan = SourceSpan(lineIndex, 0, lineContent.size)
this.line = SourceLine(lineContent, sourceSpan)
}
private Void findNextNonSpace()
{
i := this.index
cols := this.column
this.isBlank = true
len := line.content.size
while (i < len)
{
switch (line.content[i])
{
case ' ':
++i
++cols
continue
case '\t':
// move cols to next tabstop
++i
cols += Parsing.columnsToNextTabStop(cols)
continue
}
this.isBlank = false
break
}
this.nextNonSpaceIndex = i
this.nextNonSpaceColumn = cols
this.indent = nextNonSpaceColumn - this.column
}
private Void setNewIndex(Int newIndex)
{
if (newIndex >= nextNonSpaceIndex)
{
// we can start from here, no need to calculate tab stops again
this.index = nextNonSpaceIndex
this.column = nextNonSpaceColumn
}
len := line.content.size
while (index < newIndex && index != len) advance
// If we're going to an index as opposed to a column, we're never within a tab
this.columnIsInTab = false
}
private Void setNewColumn(Int newColumn)
{
if (newColumn >= nextNonSpaceColumn)
{
// we can start from here, no need to calcualte tab stops again
this.index = this.nextNonSpaceIndex
this.column = this.nextNonSpaceColumn
}
len := line.content.size
while (column < newColumn && index != len) advance
if (column > newColumn)
{
// last character was a tab and we overshot our target
--index
this.column = newColumn
this.columnIsInTab = true
}
else
{
this.columnIsInTab = false
}
}
** Advance to the next character in the line and update
** column taking into account tab stops.
private Void advance()
{
c := line.content[index]
index++
if (c == '\t')
column += Parsing.columnsToNextTabStop(column)
else
column++
}
** Add line content to the active block parser. We assume it can accept lines --
** that check should be done before calling this.
private Void addLine()
{
Str? content := null
if (columnIsInTab)
{
// Our column is in a partially consumed tab. Expand the remaining
// columns (to the next tab stop) to spaces
afterTab := index + 1
rest := line.content[afterTab..-1]
spaces := Parsing.columnsToNextTabStop(column)
sb := StrBuf(spaces + rest.size)
spaces.times { sb.addChar(' ') }
sb.add(rest)
content = sb.toStr
}
else if (index == 0)
{
content = line.content
}
else
{
content = line.content[index..<line.content.size]
}
SourceSpan? sourceSpan := null
if (includeSourceSpans === IncludeSourceSpans.blocks_and_inlines)
{
// Note that if we're in a partially-consume tab, the length here corresponds to
// the content but not to the actual source length. That sounds like a problem,
// but I haven't found a test case where it matters (yet).
sourceSpan = SourceSpan(lineIndex, index, content.size)
}
activeBlockParser.addLine(SourceLine(content, sourceSpan))
addSourceSpans
}
private Void addSourceSpans()
{
if (includeSourceSpans === IncludeSourceSpans.none) return
throw Err("TODO:HERE")
}
private BlockStart? findBlockStart(BlockParser blockParser)
{
matchedBlockParser := MatchedBlockParser(blockParser)
return blockParserFactories.eachWhile |factory|
{
factory.tryStart(this, matchedBlockParser)
}
}
** Walk through a block & children recursively, parsing string content into
** inline content where appropriate.
private Void processInLines()
{
cx := InlineParserContext(parser, definitions)
inlineParser := DefaultInlineParser(cx)
allBlockParsers.each |blockParser| { blockParser.parseInlines(inlineParser) }
}
** Add block of type tag as a child of the tip. If the tip can't accept children,
** close and finalize it and try its parent, and so on until we find a block
** that can accept children.
private Void addChild(OpenBlockParser openBlockParser)
{
block := openBlockParser.blockParser.block
while (!activeBlockParser.canContain(block))
{
closeBlockParsers(1)
}
activeBlockParser.block.appendChild(block)
activateBlockParser(openBlockParser)
}
private Void activateBlockParser(OpenBlockParser openBlockParser)
{
openBlockParsers.add(openBlockParser)
}
private OpenBlockParser deactivateBlockParser()
{
openBlockParsers.pop
}
private Block prepareActiveBlockParserForReplacement()
{
// Note that we don't want to parse inlines, as it's getting replaced
old := deactivateBlockParser.blockParser
if (old is ParagraphParser)
{
// Collect any link reference definitions. Note that replacing the
// active block parser is done after a block parser got the current
// paragraph content using MatchedBlockParer.content. In case the
// paragraph started with link reference definitions, we parse and strip
// them before the block parser gets the content. We want to keep them.
// If no replacement happens, we collect the definitions as part of
// finalizing blocks
addDefinitionsFrom((ParagraphParser)old)
}
// Do this so that source positions are calculated, which we will carry over
// to the replacing block
old.closeBlock
old.block.unlink
return old.block
}
** Prepares the input line by replacing '\0' characters with 'U+FFFD'.
** See § 2.3 - Insecure Characters.
private static Str prepareLine(Str line)
{
if (!line.containsChar('\u0000')) return line
return line.replace("\u0000", "\uFFFD")
}
//////////////////////////////////////////////////////////////////////////
// Finalize
//////////////////////////////////////////////////////////////////////////
private Document finalizeAndProcess()
{
closeBlockParsers(openBlockParsers.size)
processInLines
return documentBlockParser.block
}
private Void closeBlockParsers(Int count)
{
count.times |i|
{
blockParser := deactivateBlockParser.blockParser
finalize(blockParser)
// Remember for inline parsing. Note that a lot of blocks don't need inline
// parsing. We could have a separate mixin (e.g. BlockParserWithInlines) so that
// we donly have to remember those that actually have inlines to parse
allBlockParsers.add(blockParser)
}
}
** Finalize a block. Close it and do any necessary post-processing; e.g. setting the
** content of blocks and collecting link reference definitions from paragraphs.
private Void finalize(BlockParser blockParser)
{
addDefinitionsFrom(blockParser)
blockParser.closeBlock
}
private Void addDefinitionsFrom(BlockParser blockParser)
{
blockParser.definitions.each |DefinitionMap defMap|
{
definitions.addDefinitions(defMap)
}
}
//////////////////////////////////////////////////////////////////////////
// Debug
//////////////////////////////////////////////////////////////////////////
// @NoDoc Void dumpState()
// {
// s := StrBuf()
// s.add("line=${line.content} index=${index} col=${column} nextNonSpaceIndex=${nextNonSpaceIndex} nextNonSpaceColumn=${nextNonSpaceColumn} indent=${indent} isBlank=${isBlank}")
// echo(s)
// }
}
**************************************************************************
** OpenBlockParser
**************************************************************************
@Js
internal class OpenBlockParser
{
new make(BlockParser blockParser, Int sourceIndex)
{
this.blockParser = blockParser
this.sourceIndex = sourceIndex
}
BlockParser blockParser { private set }
Int sourceIndex
override Str toStr() { "OpenBlockParser(${blockParser} ${sourceIndex})"}
}
**************************************************************************
** MatchedBlockParser
**************************************************************************
**
** Open block parser that was last matched during the continue phase. This
** is different from the currently active block parser, as an unmatched block
** is only closed when a new block is started.
**
@Js
class MatchedBlockParser
{
new make(BlockParser matchedBlockParser)
{
this.matchedBlockParser = matchedBlockParser
}
BlockParser matchedBlockParser { private set }
SourceLines paragraphLines()
{
if (matchedBlockParser is ParagraphParser)
{
return ((ParagraphParser)matchedBlockParser).paragraphLines
}
return SourceLines.empty
}
}
**************************************************************************
** Parsing
**************************************************************************
@Js
@NoDoc class Parsing
{
static const Int code_block_indent := 4
static Int columnsToNextTabStop(Int column)
{
// Tab stop is 4
4 - (column % 4)
}
}