Document most of the JS 'Document' API

This commit is contained in:
Joshua Bemenderfer
2023-02-15 22:43:58 -05:00
parent c1ac40905d
commit 107a164ec8
4 changed files with 274 additions and 29 deletions

View File

@@ -1,8 +1,10 @@
import type { Reader } from './readers/reader'
import { createLineData, parseLine } from './parser'
// Container for a handful of convenience functions for parsing documents
// Obtained from useDocument() below
export type Document = {
next: (startLevel?: number) => Promise<boolean>
next: (levelScope?: number) => Promise<boolean>
level: () => number,
line: (startOffset?: number) => string,
head: () => string,
@@ -10,39 +12,147 @@ export type Document = {
match: (matchValue: string) => boolean
}
/**
* Provides a simple set of convenience functions around parseLine for more ergonomic parsing of Terrace documents
*
* @param {Reader} reader When called, resolves to a string containing the next line in the document
* @param {String} indent The character used for indentation in the document. Only a single character is permitted
* @returns {Document} A set of convenience functions for iterating through and parsing a document line by line
*/
export function useDocument (reader: Reader, indent: string = ' '): Document {
if (indent.length !== 1) throw new Error(`Terrace currently only allows single-character indent strings - you passed "${indent}"`)
const lineData = createLineData('', indent)
let repeat = false
async function next(startLevel: number = -1): Promise<boolean> {
// If `repeatCurrentLine` is `true`, the following call to `next()` will repeat the current line in
// the document and set `repeatCurrentLine` back to `false`
let repeatCurrentLine = false
/**
* Advances the current position in the terrace document and populates lineData
* with the parsed information from that line
*
* Returns `true` after parsing the next line, or `false` upon reaching the end of the document.
* If the `levelScope` parameter is provided, `next()` will return `false` when it encounters a line
* with a level at or below `levelScope`. This allows you to iterate through subsections of a document.
*
* If a lower-level line was encountered, the following call to `next()` will repeat this line again.
* This 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.
*
* Intended to be used inside a while loop to parse a section of a Terrace document.
*
* ```javascript
* while (await next()) {
* // Do something with each line.
* }
* ```
*
* @param {number} levelScope If specified, `next()` will return `false` when it encounters a line with a level at or below `levelScope`
* @returns {Promise<boolean>} Returns `true` after parsing a line, or `false` if the document has ended or a line at or below `levelScope` has been encountered.
*/
async function next(levelScope: number = -1): Promise<boolean> {
// Repeat the current line instead of parsing a new one if the previous call to next()
// determined the current line to be out of its scope.
if (repeat) repeat = false
if (repeatCurrentLine) repeatCurrentLine = false
// Otherwise parse the line normally.
else {
// Load the next line from the line reader.
const line = await reader()
// If there are no more lines, bail out.
if (line == null) return false
// Populate lineData with parsed information from the current line.
lineData.line = line
parseLine(lineData)
}
// If we shouldn't be handling this line, make the next call to next() repeat the current line.
// If we shouldn't be handling this line, make the following call to next() 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.
if (level() <= startLevel) {
repeat = true
if (level() <= levelScope) {
repeatCurrentLine = true
return false
}
return true
}
const level = () => lineData.level
const line = (startOffset: number = lineData.offsetHead) => lineData.line.slice(startOffset)
const head = () => lineData.line.slice(lineData.offsetHead, lineData.offsetTail)
const tail = () => lineData.line.slice(lineData.offsetTail + 1) // Skip the space
/**
* Returns the number of indent characters of the current line
*
* Given the following document, `level()` would return 0, 1, 2, and 5 respectively for each line
*
* ```terrace
* block
* block
* block
* block
* ```
* @returns {number} The indent level of the current line
*/
const level = (): number => lineData.level
/**
* Get a string with the current line contents. Skips all indent characters by default, but this can be configured with `startOffset`
*
* Given the following document
*
* ```terrace
* root
* sub-line
* ```
* `line()` on the second line returns "sub-line", trimming off the leading indent characters
* `line(0)` however, returns " sub-line", with all four leading spaces
*
* `startOffset` is primarily used for parsing blocks that have literal indented multi-line text, such as markdown
*
* @param {number} startOffset How many indent characters to skip before outputting the line contents. Defaults to the current indent level
* @returns {string} The line contents starting from `startOffset`
*/
const line = (startOffset: number = lineData.level): string => lineData.line.slice(startOffset)
/**
* Get the first "word" of a line, starting from the first non-indent character to the first space or end of the line
* Often used for deciding how to parse a block.
*
* Terrace DSLs do not *need* to use head-tail line structure, but support for them is built into the parser
*
* Given the following line, `head()` returns "title"
*
* ```terrace
* title An Important Document
* ```
* @returns {string} The `head` portion (first word) of a line
*/
const head = (): string => lineData.line.slice(lineData.offsetHead, lineData.offsetTail)
/**
* Get all text following the first "word" of a line, starting from the first character after the space at the end of `head()`
*
* Terrace DSLs do not *need* to use head-tail line structure, but support for them is built into the parser
*
* Given the following line, `tail()` returns "An Important Document"
*
* ```terrace
* title An Important Document
* ```
* @returns {string} The remainder of the line following the `head()` portion, with no leading space
*/
const tail = (): string => lineData.line.slice(lineData.offsetTail + 1) // Skip the space
/**
* Quickly check if the current line head matches a specified value
*
* Shorthand for `matchValue === head()`
*
* Given the following line
*
* ```terrace
* title An Important Document
* ```
*
* `match('title')` returns `true`
* `match('somethingElse`) returns `false`
*
* @param {string} matchValue A string to check against `head()` for equality
* @returns {boolean}
*/
const match = (matchValue: string): boolean => matchValue === head()
return {