Working on API surface.

This commit is contained in:
Joshua Bemenderfer
2022-11-12 21:30:22 -05:00
parent a0791b0c69
commit 28de2a8e20
18 changed files with 325 additions and 162 deletions

View File

@@ -1,20 +1,74 @@
import { LineData, parseLine } from './parser'
import type { Reader } from './readers/reader'
import { createLineData, parseLine } from './parser'
export function document (nextFn: () => Promise<string|null>, indent: string = ' ') {
let line: string|null = null
let lineData = LineData()
let ended = false
async function next() {
line = await nextFn()
if (line === null) ended = true
else parseLine(line, lineData, indent)
return { line, lineData, ended, next, current }
}
function current() {
return { line, lineData, ended, next, current }
}
return { next, current }
type Document = {
ended: boolean,
clone: () => Document,
next: () => Promise<Document>
current: () => Document
line: () => string,
head: () => string,
tail: () => string,
content: (contentLevel: number, lines: string[]) => Promise<string>,
seek: (matchHead: string, contentLevel: number) => Promise<Document|false>
}
export function useDocument (reader: Reader, indent: string = ' '): Document {
let lineData = createLineData(null, indent)
const document = {
ended: false,
clone() {
return useDocument(reader.clone(), indent)
},
async next() {
lineData.line = await reader.next()
if (lineData.line === null) return true
else parseLine(lineData)
},
current() {
return document
},
line() {
return lineData.line?.slice(lineData.offsetHead)
},
head () {
return lineData.line?.slice(lineData.offsetHead, lineData.offsetTail)
},
tail () {
return lineData.line?.slice(lineData.offsetTail)
},
async content (contentLevel = -1, lines: string[] = []): Promise<string> {
if (contentLevel === -1) contentLevel = lineData.level + 1
const ended = await document.next()
if (ended) return lines.join('\n')
if (lineData.level < contentLevel) return lines.join('\n')
lines.push(lineData.line?.slice(contentLevel) || '')
return document.content(contentLevel, lines)
},
async seek (matchHead: string, contentLevel = -1): Promise<Document|false> {
if (contentLevel === -1) contentLevel = lineData.level
const ended = await document.next()
if (ended) return false
if (document.head() === matchHead) return document
if (lineData.level < contentLevel) return false
return document.seek(matchHead, contentLevel)
}
}
return document
}

View File

@@ -1,145 +1,143 @@
import { describe, expect, it } from 'vitest'
import { LineData, parseLine } from './parser'
import { createLineData, parseLine } from './parser'
describe(`LineData`, () => {
it(`is an object`, () => {
const lineData = LineData()
const lineData = createLineData()
expect(lineData).toBeTypeOf(`object`)
})
it(`has four properties`, () => {
const lineData = LineData()
expect(Object.keys(lineData).length).to.equal(4)
it(`has five properties`, () => {
const lineData = createLineData()
expect(Object.keys(lineData).length).to.equal(6)
})
it(`'line' is a string|null initialized to null`, () => {
const lineData = createLineData()
expect(lineData.level).to.equal(0)
})
it(`'type' is an integer initialized to zero`, () => {
const lineData = LineData()
const lineData = createLineData()
expect(lineData.level).to.equal(0)
})
it(`'level' is an integer initialized to zero`, () => {
const lineData = LineData()
const lineData = createLineData()
expect(lineData.type).to.equal(0)
})
it(`'offsetHead' is an integer initialized to zero`, () => {
const lineData = LineData()
const lineData = createLineData()
expect(lineData.offsetHead).to.equal(0)
})
it(`'offsetTail' is an integer initialized to zero`, () => {
const lineData = LineData()
const lineData = createLineData()
expect(lineData.offsetTail).to.equal(0)
})
})
describe(`parseLine`, () => {
it(`Requres 'line' to be a string`, () => {
const lineData = LineData()
expect(() => parseLine(0, lineData)).toThrowError(`'line' must be a string`)
it(`Requres 'lineData' to be an object with a string line property and numeric level and type properties`, () => {
const lineData = createLineData()
// @ts-ignore
expect(() => parseLine([], lineData)).toThrowError(`'line' must be a string`)
expect(() => parseLine(``, 0)).toThrowError(`'lineData' must be an object with 'line' string, and 'type' and 'level' integer properties`)
// @ts-ignore
expect(() => parseLine({}, lineData)).toThrowError(`'line' must be a string`)
expect(() => parseLine(``, [])).toThrowError(`'lineData' must be an object with 'line' string, and 'type' and 'level' integer properties`)
// @ts-ignore
expect(() => parseLine(null, lineData)).toThrowError(`'line' must be a string`)
expect(() => parseLine(``, {})).toThrowError(`'lineData' must be an object with 'line' string, and 'type' and 'level' integer properties`)
// @ts-ignore
expect(() => parseLine(true, lineData)).toThrowError(`'line' must be a string`)
expect(() => parseLine(``, null)).toThrowError(`'lineData' must be an object with 'line' string, and 'type' and 'level' integer properties`)
// @ts-ignore
expect(() => parseLine(() => {}, lineData)).toThrowError(`'line' must be a string`)
})
it(`Requres 'lineData' to be an object with numeric level and type properties`, () => {
const lineData = LineData()
expect(() => parseLine(``, true)).toThrowError(`'lineData' must be an object with 'line' string, and 'type' and 'level' integer properties`)
// @ts-ignore
expect(() => parseLine(``, 0)).toThrowError(`'lineData' must be an object with 'type' and 'level' integer properties`)
expect(() => parseLine(``, () => {})).toThrowError(`'lineData' must be an object with 'line' string, and 'type' and 'level' integer properties`)
// @ts-ignore
expect(() => parseLine(``, [])).toThrowError(`'lineData' must be an object with 'type' and 'level' integer properties`)
expect(() => parseLine(``, { line: '', level: '', type: 0 })).toThrowError(`'lineData' must be an object with 'line' string, and 'type' and 'level' integer properties`)
// @ts-ignore
expect(() => parseLine(``, {})).toThrowError(`'lineData' must be an object with 'type' and 'level' integer properties`)
// @ts-ignore
expect(() => parseLine(``, null)).toThrowError(`'lineData' must be an object with 'type' and 'level' integer properties`)
// @ts-ignore
expect(() => parseLine(``, true)).toThrowError(`'lineData' must be an object with 'type' and 'level' integer properties`)
// @ts-ignore
expect(() => parseLine(``, () => {})).toThrowError(`'lineData' must be an object with 'type' and 'level' integer properties`)
// @ts-ignore
expect(() => parseLine(``, { level: '', type: 0 })).toThrowError(`'lineData' must be an object with 'type' and 'level' integer properties`)
// @ts-ignore
expect(() => parseLine(``, { level: 0, type: null })).toThrowError(`'lineData' must be an object with 'type' and 'level' integer properties`)
expect(() => parseLine(``, { line: '', level: 0, type: null })).toThrowError(`'lineData' must be an object with 'line' string, and 'type' and 'level' integer properties`)
})
it(`Requres 'indent' to be a single-character string`, () => {
const lineData = LineData()
const lineData = createLineData()
lineData.line = ``
// @ts-ignore
expect(() => parseLine(``, lineData, 0)).toThrowError(`'indent' must be a single-character string`)
lineData.indent = 0
expect(() => parseLine(lineData)).toThrowError(`'lineData.indent' must be a single-character string`)
// @ts-ignore
expect(() => parseLine(``, lineData, [])).toThrowError(`'indent' must be a single-character string`)
lineData.indent = []
expect(() => parseLine(lineData)).toThrowError(`'lineData.indent' must be a single-character string`)
// @ts-ignore
expect(() => parseLine(``, lineData, {})).toThrowError(`'indent' must be a single-character string`)
lineData.indent = {}
expect(() => parseLine(lineData)).toThrowError(`'lineData.indent' must be a single-character string`)
// @ts-ignore
expect(() => parseLine(``, lineData, null)).toThrowError(`'indent' must be a single-character string`)
lineData.indent = null
expect(() => parseLine(lineData)).toThrowError(`'lineData.indent' must be a single-character string`)
// @ts-ignore
expect(() => parseLine(``, lineData, true)).toThrowError(`'indent' must be a single-character string`)
lineData.indent = true
expect(() => parseLine(lineData)).toThrowError(`'lineData.indent' must be a single-character string`)
// @ts-ignore
expect(() => parseLine(``, lineData, () => {})).toThrowError(`'indent' must be a single-character string`)
expect(() => parseLine(``, lineData, ` `)).toThrowError(`'indent' must be a single-character string`)
lineData.indent = () => {}
expect(() => parseLine(lineData)).toThrowError(`'lineData.indent' must be a single-character string`)
lineData.indent = ` `
expect(() => parseLine(lineData)).toThrowError(`'lineData.indent' must be a single-character string`)
})
it(`Handles a blank line at indent level 0`, () => {
const line = ``
const lineData = LineData()
parseLine(line, lineData)
expect(lineData).to.deep.equal({ type: 0, level: 0, offsetHead: 0, offsetTail: 0 })
const lineData = createLineData(line)
parseLine(lineData)
expect(lineData).to.deep.equal({ line, indent: ` `, type: 0, level: 0, offsetHead: 0, offsetTail: 0 })
})
it(`Handles a line with a single space at indent level 1`, () => {
const line = ` `
const lineData = LineData()
parseLine(line, lineData)
expect(lineData).to.deep.equal({ type: 1, level: 1, offsetHead: 1, offsetTail: 1 })
const lineData = createLineData(line)
parseLine(lineData)
expect(lineData).to.deep.equal({ line, indent: ` `, type: 1, level: 1, offsetHead: 1, offsetTail: 1 })
})
it(`Handles a line with two spaces`, () => {
const line = ` `
const lineData = LineData()
parseLine(line, lineData)
expect(lineData).to.deep.equal({ type: 1, level: 2, offsetHead: 2, offsetTail: 2 })
const lineData = createLineData(line)
parseLine(lineData)
expect(lineData).to.deep.equal({ line, indent: ` `, type: 1, level: 2, offsetHead: 2, offsetTail: 2 })
})
it(`Handles a normal line at indent level 0`, () => {
const line = `line 1`
const lineData = LineData()
parseLine(line, lineData)
expect(lineData).to.deep.equal({ type: 1, level: 0, offsetHead: 0, offsetTail: 4 })
const lineData = createLineData(line)
parseLine(lineData)
expect(lineData).to.deep.equal({ line, indent: ` `, type: 1, level: 0, offsetHead: 0, offsetTail: 4 })
})
it(`Handles a normal line at indent level 1`, () => {
const line = ` line 1`
const lineData = LineData()
parseLine(line, lineData)
expect(lineData).to.deep.equal({ type: 1, level: 1, offsetHead: 1, offsetTail: 5 })
const lineData = createLineData(line)
parseLine(lineData)
expect(lineData).to.deep.equal({ line, indent: ` `, type: 1, level: 1, offsetHead: 1, offsetTail: 5 })
})
it(`Handles a normal line at indent level 2`, () => {
const line = ` line 1`
const lineData = LineData()
parseLine(line, lineData)
expect(lineData).to.deep.equal({ type: 1, level: 2, offsetHead: 2, offsetTail: 6 })
const lineData = createLineData(line)
parseLine(lineData)
expect(lineData).to.deep.equal({ line, indent: ` `, type: 1, level: 2, offsetHead: 2, offsetTail: 6 })
})
it(`Handles a normal line at indent level 1 indented with tabs`, () => {
const line = `\tline 1`
const lineData = LineData()
parseLine(line, lineData, `\t`)
expect(lineData).to.deep.equal({ type: 1, level: 1, offsetHead: 1, offsetTail: 5 })
const lineData = createLineData(line, `\t`)
parseLine(lineData)
expect(lineData).to.deep.equal({ line, indent: `\t`, type: 1, level: 1, offsetHead: 1, offsetTail: 5 })
})
it(`Handles a normal line at indent level 2 indented with tabs`, () => {
const line = `\t\tline 1`
const lineData = LineData()
parseLine(line, lineData, `\t`)
expect(lineData).to.deep.equal({ type: 1, level: 2, offsetHead: 2, offsetTail: 6})
const lineData = createLineData(line, `\t`)
parseLine(lineData)
expect(lineData).to.deep.equal({ line, indent: `\t`, type: 1, level: 2, offsetHead: 2, offsetTail: 6})
})
it(`Nests a normal line under a preceding normal line`, () => {
@@ -148,15 +146,16 @@ describe(`parseLine`, () => {
' line 2'
]
const lineData = LineData()
const lineData = createLineData()
const results = lines.map(line => {
parseLine(line, lineData)
lineData.line = line
parseLine(lineData)
return {...lineData}
})
expect(results).to.deep.equal([
{ type: 1, level: 0, offsetHead: 0, offsetTail: 4 },
{ type: 1, level: 1, offsetHead: 1, offsetTail: 5 }
{ line: lines[0], indent: ' ', type: 1, level: 0, offsetHead: 0, offsetTail: 4 },
{ line: lines[1], indent: ' ', type: 1, level: 1, offsetHead: 1, offsetTail: 5 }
])
})
@@ -168,17 +167,18 @@ describe(`parseLine`, () => {
' line 4',
]
const lineData = LineData()
const lineData = createLineData()
const results = lines.map(line => {
parseLine(line, lineData)
lineData.line = line
parseLine(lineData)
return {...lineData}
})
expect(results).to.deep.equal([
{ type: 1, level: 0, offsetHead: 0, offsetTail: 4 },
{ type: 1, level: 1, offsetHead: 1, offsetTail: 5 },
{ type: 1, level: 1, offsetHead: 1, offsetTail: 5 },
{ type: 1, level: 1, offsetHead: 1, offsetTail: 5 }
{ line: lines[0], indent: ' ', type: 1, level: 0, offsetHead: 0, offsetTail: 4 },
{ line: lines[1], indent: ' ', type: 1, level: 1, offsetHead: 1, offsetTail: 5 },
{ line: lines[2], indent: ' ', type: 1, level: 1, offsetHead: 1, offsetTail: 5 },
{ line: lines[3], indent: ' ', type: 1, level: 1, offsetHead: 1, offsetTail: 5 }
])
})
@@ -188,15 +188,16 @@ describe(`parseLine`, () => {
''
]
const lineData = LineData()
const lineData = createLineData()
const results = lines.map(line => {
parseLine(line, lineData)
lineData.line = line
parseLine(lineData)
return {...lineData}
})
expect(results).to.deep.equal([
{ type: 1, level: 0, offsetHead: 0, offsetTail: 4 },
{ type: 0, level: 1, offsetHead: 0, offsetTail: 0 }
{ line: lines[0], indent: ' ', type: 1, level: 0, offsetHead: 0, offsetTail: 4 },
{ line: lines[1], indent: ' ', type: 0, level: 1, offsetHead: 0, offsetTail: 0 }
])
})
@@ -208,24 +209,25 @@ describe(`parseLine`, () => {
'',
]
const lineData = LineData()
const lineData = createLineData()
const results = lines.map(line => {
parseLine(line, lineData)
lineData.line = line
parseLine(lineData)
return {...lineData}
})
expect(results).to.deep.equal([
{ type: 1, level: 0, offsetHead: 0, offsetTail: 4 },
{ type: 0, level: 1, offsetHead: 0, offsetTail: 0 },
{ type: 0, level: 1, offsetHead: 0, offsetTail: 0 },
{ type: 0, level: 1, offsetHead: 0, offsetTail: 0 }
{ line: lines[0], indent: ' ', type: 1, level: 0, offsetHead: 0, offsetTail: 4 },
{ line: lines[1], indent: ' ', type: 0, level: 1, offsetHead: 0, offsetTail: 0 },
{ line: lines[2], indent: ' ', type: 0, level: 1, offsetHead: 0, offsetTail: 0 },
{ line: lines[3], indent: ' ', type: 0, level: 1, offsetHead: 0, offsetTail: 0 }
])
})
it(`Handle head and tail matching for lines with head and tail`, () => {
const line = ` head tail1 tail2 tail3`
const lineData = LineData()
parseLine(line, lineData)
const lineData = createLineData(line)
parseLine(lineData)
const head = line.slice(lineData.offsetHead, lineData.offsetTail)
const tail = line.slice(lineData.offsetTail + 1)
@@ -235,8 +237,8 @@ describe(`parseLine`, () => {
it(`Handle head and tail matching for lines with head but no tail`, () => {
const line = ` head`
const lineData = LineData()
parseLine(line, lineData)
const lineData = createLineData(line)
parseLine(lineData)
const head = line.slice(lineData.offsetHead, lineData.offsetTail)
const tail = line.slice(lineData.offsetTail + 1)
@@ -246,8 +248,8 @@ describe(`parseLine`, () => {
it(`Handle head and tail matching for lines with head and trailing space`, () => {
const line = ` head `
const lineData = LineData()
parseLine(line, lineData)
const lineData = createLineData(line)
parseLine(lineData)
const head = line.slice(lineData.offsetHead, lineData.offsetTail)
const tail = line.slice(lineData.offsetTail + 1)

View File

@@ -1,23 +1,25 @@
type LineData = {
export type LineData = {
line: string|null;
indent: string;
type: number;
level: number;
offsetHead: number;
offsetTail: number;
}
export function LineData(): LineData {
return { type: 0, level: 0, offsetHead: 0, offsetTail: 0 }
export function createLineData(line: string|null = null, indent: string = ' '): LineData {
return { line, indent, type: 0, level: 0, offsetHead: 0, offsetTail: 0 }
}
export function parseLine(line: string, lineData: LineData, indent: string = ' '): LineData {
if (typeof line !== 'string') throw new Error(`'line' must be a string`)
if ((typeof lineData !== 'object' || !lineData) || typeof lineData.type !== 'number' || typeof lineData.level !== 'number') throw new Error(`'lineData' must be an object with 'type' and 'level' integer properties`)
if (typeof indent !== 'string' || indent.length === 0 || indent.length > 1) throw new Error(`'indent' must be a single-character string`)
export function parseLine(lineData: LineData): LineData {
if ((typeof lineData !== 'object' || !lineData) || typeof lineData.type !== 'number' || typeof lineData.level !== 'number') throw new Error(`'lineData' must be an object with 'line' string, and 'type' and 'level' integer properties`)
if (typeof lineData.indent !== 'string' || lineData.indent.length === 0 || lineData.indent.length > 1) throw new Error(`'lineData.indent' must be a single-character string`)
if (typeof lineData.line !== 'string') throw new Error(`'lineData.line' must be a string`)
let type = 0
let level = 0
if (!line.length) {
if (!lineData.line.length) {
if (lineData.type === 1) level += 1
if (lineData.type === 0) level = lineData.level
@@ -28,13 +30,13 @@ export function parseLine(line: string, lineData: LineData, indent: string = ' '
} else {
type = 1
while (line[level] === indent && level <= lineData.level + 1) ++level
while (lineData.line[level] === lineData.indent && level <= lineData.level + 1) ++level
lineData.type = type
lineData.level = level
lineData.offsetHead = level
lineData.offsetTail = level
while (line[lineData.offsetTail] && line[lineData.offsetTail] !== ' ') ++lineData.offsetTail
while (lineData.line[lineData.offsetTail] && lineData.line[lineData.offsetTail] !== ' ') ++lineData.offsetTail
}
return lineData

View File

@@ -0,0 +1,17 @@
import type { Reader } from './reader'
export function createStringReader(doc: string|string[], index = 0): Reader {
const lines = Array.isArray(doc) ? doc : doc.split('\n')
const reader = {
index: index - 1,
next: () => {
reader.index++
if (reader.index >= lines.length) return null
return lines[reader.index]
},
clone: (startIndex?: number) => createStringReader(doc, startIndex == null ? index : reader.index)
}
return reader
}

View File

@@ -0,0 +1,10 @@
import fs from 'node:fs'
import readline from 'node:readline/promises'
export function createReadlineReader(path: string): () => Promise<string|null> {
const it = readline.createInterface({
input: fs.createReadStream(path, 'utf-8'),
})[Symbol.asyncIterator]()
return async () => (await it.next()).value
}

View File

@@ -0,0 +1,5 @@
export type Reader = {
index: number,
next: () => string|null|Promise<string|null>,
clone: (startIndex?: number) => Reader
}