173 lines
6.3 KiB
TypeScript
173 lines
6.3 KiB
TypeScript
import type { Reader } from './readers/reader'
|
|
import { createLineData, parseLine } from './parser'
|
|
|
|
enum STATE {
|
|
READY = 0,
|
|
STARTED = 1,
|
|
PAUSED = 2,
|
|
ENDED = 3
|
|
}
|
|
|
|
type Document = {
|
|
next: () => Promise<boolean>
|
|
level: () => number,
|
|
line: () => string,
|
|
head: () => string,
|
|
tail: () => string,
|
|
match: (matchHead: string) => boolean,
|
|
each: (handler: Function) => void,
|
|
blockAsText: (startLevel: number, lines?: string[]) => Promise<Array<string>>,
|
|
toObject: (matchers?: { [key: string]: Function|boolean }) => { [key: string]: any },
|
|
toLineArray(): Promise<LineArray>
|
|
}
|
|
|
|
type LineArray = [string, Array<LineArray>]
|
|
|
|
export function useDocument (reader: Reader, indent: string = ' '): Document {
|
|
let state = STATE.READY
|
|
const lineData = createLineData('', indent)
|
|
|
|
async function next() {
|
|
switch (state) {
|
|
// The initial state change allows us to do some special-case handling for the initial state of lineData. TODO: Should lineData have a special inital state?
|
|
case STATE.READY:
|
|
state = STATE.STARTED
|
|
break
|
|
// If we are currently in the "paused" state, repeat the same line instead of reading the next one.
|
|
case STATE.PAUSED:
|
|
state = STATE.STARTED
|
|
return false
|
|
}
|
|
|
|
const line = await reader.next()
|
|
if (line === null) {
|
|
state = STATE.ENDED
|
|
return true
|
|
}
|
|
|
|
lineData.line = line
|
|
parseLine(lineData)
|
|
return false
|
|
}
|
|
|
|
// If we pause, the next call to next() will repeat the current line.
|
|
// Allows a child loop to look forward, determine that the next line will be outside its purview,
|
|
// and return control to the calling loop transparently without additional logic.
|
|
const pause = () => state = STATE.PAUSED
|
|
const level = () => lineData.level
|
|
const line = () => lineData.line.slice(lineData.offsetHead)
|
|
const head = () => lineData.line.slice(lineData.offsetHead, lineData.offsetTail)
|
|
const tail = () => lineData.line.slice(lineData.offsetTail)
|
|
const match = (matchHead: string): boolean => matchHead === head()
|
|
|
|
async function each(handler: Function) {
|
|
// Set startLevel to -1 if we haven't started parsing the document yet.
|
|
// Otherwise we'll break to early, as the default value for doc.level() is 0.
|
|
const startLevel = state === STATE.READY ? -1 : level()
|
|
|
|
while(true) {
|
|
if (await next()) return
|
|
// If we've reached the next block outside the level of this one, "pause", so that the next time "next" is called, we repeat the same line.
|
|
if (level() <= startLevel) return pause()
|
|
// If the handler returns true, exit.
|
|
if (await handler()) return
|
|
}
|
|
}
|
|
|
|
async function blockAsText (startLevel: number = -1, blockLines: string[] = [line()]): Promise<Array<string>> {
|
|
if (startLevel === -1) startLevel = level() + 1
|
|
|
|
if (await next()) return blockLines
|
|
if (level() < startLevel) { pause(); return blockLines }
|
|
|
|
blockLines.push(lineData.line?.slice(startLevel) || '')
|
|
return blockAsText(startLevel, blockLines)
|
|
}
|
|
|
|
async function toObject (inputMatchers: { [key: string]: Function|boolean|{ type: string, handle: Function } } = {}) {
|
|
const obj: { [key: string]: any } = {}
|
|
|
|
let matchers: { [key: string]: {type: string, handle: Function } } = {}
|
|
|
|
// Normalize the matchers to an object-based format despite allowing flexible input types for convenience.
|
|
// TODO: Decide whether to enforce verbose input once a DSL has been created.
|
|
if (!Object.keys(inputMatchers).length) {
|
|
// Default matcher
|
|
matchers = { '#any': { type: 'normal', handle: () => tail().trim() } }
|
|
} else {
|
|
Object.keys(inputMatchers).forEach(key => {
|
|
// If a matcher is specified as `true`, treat as a key-value pair where { [head]: tail }
|
|
if(inputMatchers[key] === true) matchers[key] = { type: 'normal', handle: () => tail().trim() }
|
|
// If a matcher is specified as a function, treat as a key-value pair where { [head]: handle() }
|
|
if (typeof inputMatchers[key] === 'function') matchers[key] = { type: 'normal', handle: inputMatchers[key] as Function }
|
|
// If a matcher is specified as an object, allow customization of the type and handle for various cases.
|
|
if (typeof inputMatchers[key] === 'object') matchers[key] = inputMatchers[key] as { type: string, handle: Function }
|
|
})
|
|
}
|
|
|
|
await each(async () => {
|
|
const currHead = head()
|
|
if (!currHead) return
|
|
|
|
const currentMatcher = matchers[currHead] || matchers['#any']
|
|
if (!currentMatcher) return
|
|
|
|
if (currentMatcher.type === 'normal') obj[currHead] = await currentMatcher.handle()
|
|
// Allows matching the same key more than once.
|
|
else if (currentMatcher.type === 'collection') {
|
|
if (!obj[currHead]) obj[currHead] = []
|
|
obj[currHead].push(await currentMatcher.handle())
|
|
}
|
|
// If matchers[currHead] or matchers[#any] is a function, set object key to its output.
|
|
// If we get to this point and matchers[#text] is set, parse all remaining block contents as text.
|
|
// TODO: I still don't like this.
|
|
else if (matchers?.['#text']) obj['#text'] = await blockAsText(level())
|
|
|
|
// Bail early as soon as we know all keys have been matched.
|
|
if (matchers && Object.keys(matchers).every(key => {
|
|
// If we have any collection keys, we have to continue searching all the way to the end of the current block
|
|
// as there may be more than one entry.
|
|
if (['collection'].includes(matchers[key].type)) return false
|
|
return obj[key] !== undefined
|
|
})) return true
|
|
})
|
|
return obj
|
|
}
|
|
|
|
async function toLineArray (): Promise<LineArray> {
|
|
const levelTracker: Array<LineArray> = [['root', []]]
|
|
|
|
// Simple parser that produces canonical array structure for blocks.
|
|
while (true) {
|
|
// If next() returns true we've ended the
|
|
if (await next()) break
|
|
const parentLevel = level()
|
|
const scopeLevel = parentLevel + 1
|
|
// Determine parent for this scope.
|
|
const parent = levelTracker[parentLevel]
|
|
// If there's no parent, skip this line.
|
|
if (!parent) continue
|
|
|
|
levelTracker.length = scopeLevel
|
|
const scope = levelTracker[scopeLevel] = [line(), []]
|
|
// Add current scope to parent.
|
|
parent[1].push(scope)
|
|
}
|
|
|
|
return levelTracker[0]
|
|
}
|
|
|
|
return {
|
|
next,
|
|
line,
|
|
head,
|
|
tail,
|
|
level,
|
|
match,
|
|
each,
|
|
blockAsText,
|
|
toObject,
|
|
toLineArray,
|
|
}
|
|
}
|