Start working on tests.
This commit is contained in:
56
packages/js/src/document.ts
Normal file
56
packages/js/src/document.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { Reader } from './readers/reader'
|
||||
import { createLineData, parseLine } from './parser'
|
||||
|
||||
export type Document = {
|
||||
next: (startLevel?: number) => Promise<boolean>
|
||||
level: () => number,
|
||||
line: (startOffset?: number) => string,
|
||||
head: () => string,
|
||||
tail: () => string,
|
||||
match: (matchHead: string) => boolean
|
||||
}
|
||||
|
||||
export function useDocument (reader: Reader, indent: string = ' '): Document {
|
||||
const lineData = createLineData('', indent)
|
||||
|
||||
let repeat = false
|
||||
async function next(startLevel: 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
|
||||
// Otherwise parse the line normally.
|
||||
else {
|
||||
const line = await reader()
|
||||
// If there are no more lines, bail out.
|
||||
if (line == null) return false
|
||||
|
||||
lineData.line = line
|
||||
parseLine(lineData)
|
||||
}
|
||||
|
||||
// If we shouldn't be handling this line, make the next 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
|
||||
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
|
||||
const match = (matchHead: string): boolean => matchHead === head()
|
||||
|
||||
return {
|
||||
next,
|
||||
level,
|
||||
line,
|
||||
head,
|
||||
tail,
|
||||
match
|
||||
}
|
||||
}
|
||||
2
packages/js/src/index.ts
Normal file
2
packages/js/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './parser'
|
||||
export * from './document'
|
||||
253
packages/js/src/parser.test.ts
Normal file
253
packages/js/src/parser.test.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createLineData, parseLine } from './parser'
|
||||
|
||||
describe(`LineData`, () => {
|
||||
it(`is an object`, () => {
|
||||
const lineData = createLineData()
|
||||
expect(lineData).toBeTypeOf(`object`)
|
||||
})
|
||||
|
||||
it(`has five properties`, () => {
|
||||
const lineData = createLineData()
|
||||
expect(Object.keys(lineData).length).to.equal(5)
|
||||
})
|
||||
|
||||
it(`'line' is a string|null initialized to null`, () => {
|
||||
const lineData = createLineData()
|
||||
expect(lineData.level).to.equal(0)
|
||||
})
|
||||
|
||||
it(`'level' is an integer initialized to zero`, () => {
|
||||
const lineData = createLineData()
|
||||
expect(lineData.level).to.equal(0)
|
||||
})
|
||||
|
||||
it(`'offsetHead' is an integer initialized to zero`, () => {
|
||||
const lineData = createLineData()
|
||||
expect(lineData.offsetHead).to.equal(0)
|
||||
})
|
||||
|
||||
it(`'offsetTail' is an integer initialized to zero`, () => {
|
||||
const lineData = createLineData()
|
||||
expect(lineData.offsetTail).to.equal(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`parseLine`, () => {
|
||||
it(`Requres 'lineData' to be an object with string line and numeric level properties`, () => {
|
||||
// @ts-ignore
|
||||
expect(() => parseLine(``, 0)).toThrowError(`'lineData' must be an object with string line and numeric level properties`)
|
||||
// @ts-ignore
|
||||
expect(() => parseLine(``, [])).toThrowError(`'lineData' must be an object with string line and numeric level properties`)
|
||||
// @ts-ignore
|
||||
expect(() => parseLine(``, {})).toThrowError(`'lineData' must be an object with string line and numeric level properties`)
|
||||
// @ts-ignore
|
||||
expect(() => parseLine(``, null)).toThrowError(`'lineData' must be an object with string line and numeric level properties`)
|
||||
// @ts-ignore
|
||||
expect(() => parseLine(``, true)).toThrowError(`'lineData' must be an object with string line and numeric level properties`)
|
||||
// @ts-ignore
|
||||
expect(() => parseLine(``, () => {})).toThrowError(`'lineData' must be an object with string line and numeric level properties`)
|
||||
// @ts-ignore
|
||||
expect(() => parseLine(``, { line: '', level: '' })).toThrowError(`'lineData' must be an object with string line and numeric level properties`)
|
||||
// @ts-ignore
|
||||
expect(() => parseLine(``, { line: '', level: 0 })).toThrowError(`'lineData' must be an object with string line and numeric level properties`)
|
||||
})
|
||||
|
||||
it(`Requres 'indent' to be a single-character string`, () => {
|
||||
const lineData = createLineData()
|
||||
lineData.line = ``
|
||||
// @ts-ignore
|
||||
lineData.indent = 0
|
||||
expect(() => parseLine(lineData)).toThrowError(`'lineData.indent' must be a single-character string`)
|
||||
// @ts-ignore
|
||||
lineData.indent = []
|
||||
expect(() => parseLine(lineData)).toThrowError(`'lineData.indent' must be a single-character string`)
|
||||
// @ts-ignore
|
||||
lineData.indent = {}
|
||||
expect(() => parseLine(lineData)).toThrowError(`'lineData.indent' must be a single-character string`)
|
||||
// @ts-ignore
|
||||
lineData.indent = null
|
||||
expect(() => parseLine(lineData)).toThrowError(`'lineData.indent' must be a single-character string`)
|
||||
// @ts-ignore
|
||||
lineData.indent = true
|
||||
expect(() => parseLine(lineData)).toThrowError(`'lineData.indent' must be a single-character string`)
|
||||
// @ts-ignore
|
||||
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 = createLineData(line)
|
||||
parseLine(lineData)
|
||||
expect(lineData).to.deep.equal({ line, indent: ` `, level: 0, offsetHead: 0, offsetTail: 0 })
|
||||
})
|
||||
|
||||
it(`Handles a line with a single space at indent level 1`, () => {
|
||||
const line = ` `
|
||||
const lineData = createLineData(line)
|
||||
parseLine(lineData)
|
||||
expect(lineData).to.deep.equal({ line, indent: ` `, level: 1, offsetHead: 1, offsetTail: 1 })
|
||||
})
|
||||
|
||||
it(`Handles a line with two spaces`, () => {
|
||||
const line = ` `
|
||||
const lineData = createLineData(line)
|
||||
parseLine(lineData)
|
||||
expect(lineData).to.deep.equal({ line, indent: ` `, level: 2, offsetHead: 2, offsetTail: 2 })
|
||||
})
|
||||
|
||||
it(`Handles a normal line at indent level 0`, () => {
|
||||
const line = `line 1`
|
||||
const lineData = createLineData(line)
|
||||
parseLine(lineData)
|
||||
expect(lineData).to.deep.equal({ line, indent: ` `, level: 0, offsetHead: 0, offsetTail: 4 })
|
||||
})
|
||||
|
||||
it(`Handles a normal line at indent level 1`, () => {
|
||||
const line = ` line 1`
|
||||
const lineData = createLineData(line)
|
||||
parseLine(lineData)
|
||||
expect(lineData).to.deep.equal({ line, indent: ` `, level: 1, offsetHead: 1, offsetTail: 5 })
|
||||
})
|
||||
|
||||
it(`Handles a normal line at indent level 2`, () => {
|
||||
const line = ` line 1`
|
||||
const lineData = createLineData(line)
|
||||
parseLine(lineData)
|
||||
expect(lineData).to.deep.equal({ line, indent: ` `, level: 2, offsetHead: 2, offsetTail: 6 })
|
||||
})
|
||||
|
||||
it(`Handles a normal line at indent level 1 indented with tabs`, () => {
|
||||
const line = `\tline 1`
|
||||
const lineData = createLineData(line, `\t`)
|
||||
parseLine(lineData)
|
||||
expect(lineData).to.deep.equal({ line, indent: `\t`, 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 = createLineData(line, `\t`)
|
||||
parseLine(lineData)
|
||||
expect(lineData).to.deep.equal({ line, indent: `\t`, level: 2, offsetHead: 2, offsetTail: 6})
|
||||
})
|
||||
|
||||
it(`Nests a normal line under a preceding normal line`, () => {
|
||||
const lines = [
|
||||
'line 1',
|
||||
' line 2'
|
||||
]
|
||||
|
||||
const lineData = createLineData()
|
||||
const results = lines.map(line => {
|
||||
lineData.line = line
|
||||
parseLine(lineData)
|
||||
return {...lineData}
|
||||
})
|
||||
|
||||
expect(results).to.deep.equal([
|
||||
{ line: lines[0], indent: ' ', level: 0, offsetHead: 0, offsetTail: 4 },
|
||||
{ line: lines[1], indent: ' ', level: 1, offsetHead: 1, offsetTail: 5 }
|
||||
])
|
||||
})
|
||||
|
||||
it(`Nests multiple normal line under a preceding normal line`, () => {
|
||||
const lines = [
|
||||
'line 1',
|
||||
' line 2',
|
||||
' line 3',
|
||||
' line 4',
|
||||
]
|
||||
|
||||
const lineData = createLineData()
|
||||
const results = lines.map(line => {
|
||||
lineData.line = line
|
||||
parseLine(lineData)
|
||||
return {...lineData}
|
||||
})
|
||||
|
||||
expect(results).to.deep.equal([
|
||||
{ line: lines[0], indent: ' ', level: 0, offsetHead: 0, offsetTail: 4 },
|
||||
{ line: lines[1], indent: ' ', level: 1, offsetHead: 1, offsetTail: 5 },
|
||||
{ line: lines[2], indent: ' ', level: 1, offsetHead: 1, offsetTail: 5 },
|
||||
{ line: lines[3], indent: ' ', level: 1, offsetHead: 1, offsetTail: 5 }
|
||||
])
|
||||
})
|
||||
|
||||
it(`Does not nest an empty line under a preceding normal line`, () => {
|
||||
const lines = [
|
||||
'line 1',
|
||||
''
|
||||
]
|
||||
|
||||
const lineData = createLineData()
|
||||
const results = lines.map(line => {
|
||||
lineData.line = line
|
||||
parseLine(lineData)
|
||||
return {...lineData}
|
||||
})
|
||||
|
||||
expect(results).to.deep.equal([
|
||||
{ line: lines[0], indent: ' ', level: 0, offsetHead: 0, offsetTail: 4 },
|
||||
{ line: lines[1], indent: ' ', level: 0, offsetHead: 0, offsetTail: 0 }
|
||||
])
|
||||
})
|
||||
|
||||
it(`Does not nest multiple empty lines under a preceding normal line`, () => {
|
||||
const lines = [
|
||||
'line 1',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
]
|
||||
|
||||
const lineData = createLineData()
|
||||
const results = lines.map(line => {
|
||||
lineData.line = line
|
||||
parseLine(lineData)
|
||||
return {...lineData}
|
||||
})
|
||||
|
||||
expect(results).to.deep.equal([
|
||||
{ line: lines[0], indent: ' ', level: 0, offsetHead: 0, offsetTail: 4 },
|
||||
{ line: lines[1], indent: ' ', level: 0, offsetHead: 0, offsetTail: 0 },
|
||||
{ line: lines[2], indent: ' ', level: 0, offsetHead: 0, offsetTail: 0 },
|
||||
{ line: lines[3], indent: ' ', level: 0, offsetHead: 0, offsetTail: 0 }
|
||||
])
|
||||
})
|
||||
|
||||
it(`Handle head and tail matching for lines with head and tail`, () => {
|
||||
const line = ` head tail1 tail2 tail3`
|
||||
const lineData = createLineData(line)
|
||||
parseLine(lineData)
|
||||
|
||||
const head = line.slice(lineData.offsetHead, lineData.offsetTail)
|
||||
const tail = line.slice(lineData.offsetTail + 1)
|
||||
expect(head).to.equal(`head`)
|
||||
expect(tail).to.equal(`tail1 tail2 tail3`)
|
||||
})
|
||||
|
||||
it(`Handle head and tail matching for lines with head but no tail`, () => {
|
||||
const line = ` head`
|
||||
const lineData = createLineData(line)
|
||||
parseLine(lineData)
|
||||
|
||||
const head = line.slice(lineData.offsetHead, lineData.offsetTail)
|
||||
const tail = line.slice(lineData.offsetTail + 1)
|
||||
expect(head).to.equal(`head`)
|
||||
expect(tail).to.equal(``)
|
||||
})
|
||||
|
||||
it(`Handle head and tail matching for lines with head and trailing space`, () => {
|
||||
const line = ` head `
|
||||
const lineData = createLineData(line)
|
||||
parseLine(lineData)
|
||||
|
||||
const head = line.slice(lineData.offsetHead, lineData.offsetTail)
|
||||
const tail = line.slice(lineData.offsetTail + 1)
|
||||
expect(head).to.equal(`head`)
|
||||
expect(tail).to.equal(``)
|
||||
})
|
||||
})
|
||||
35
packages/js/src/parser.ts
Normal file
35
packages/js/src/parser.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export type LineData = {
|
||||
line: string;
|
||||
indent: string;
|
||||
level: number;
|
||||
offsetHead: number;
|
||||
offsetTail: number;
|
||||
}
|
||||
|
||||
export function createLineData(line: string = '', indent: string = ' '): LineData {
|
||||
return { line, indent, level: 0, offsetHead: 0, offsetTail: 0 }
|
||||
}
|
||||
|
||||
export function parseLine(lineData: LineData): LineData {
|
||||
if ((typeof lineData !== 'object' || !lineData) || typeof lineData.level !== 'number') throw new Error(`'lineData' must be an object with string line and numeric level 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 level = 0
|
||||
|
||||
// Repeat previous level for blank lines.
|
||||
if (!lineData.line.length) {
|
||||
lineData.level = lineData.level
|
||||
lineData.offsetHead = 0
|
||||
lineData.offsetTail = 0
|
||||
} else {
|
||||
while (lineData.line[level] === lineData.indent && level <= lineData.level + 1) ++level
|
||||
lineData.level = level
|
||||
lineData.offsetHead = level
|
||||
lineData.offsetTail = level
|
||||
|
||||
while (lineData.line[lineData.offsetTail] && lineData.line[lineData.offsetTail] !== ' ') ++lineData.offsetTail
|
||||
}
|
||||
|
||||
return lineData
|
||||
}
|
||||
13
packages/js/src/readers/js-string.ts
Normal file
13
packages/js/src/readers/js-string.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Reader } from './reader'
|
||||
|
||||
export function createStringReader(doc: string|string[], index = 0): Reader {
|
||||
const lines = Array.isArray(doc) ? doc : doc.split('\n')
|
||||
|
||||
index--;
|
||||
|
||||
return () => {
|
||||
index++
|
||||
if (index >= lines.length) return null
|
||||
return lines[index]
|
||||
}
|
||||
}
|
||||
19
packages/js/src/readers/node-readline.ts
Normal file
19
packages/js/src/readers/node-readline.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import fs from 'node:fs'
|
||||
import readline from 'node:readline/promises'
|
||||
import type { Reader } from './reader'
|
||||
|
||||
export function createFileReader(path: string): Reader {
|
||||
const it = readline.createInterface({
|
||||
input: fs.createReadStream(path, 'utf-8'),
|
||||
})[Symbol.asyncIterator]()
|
||||
|
||||
return async () => (await it.next()).value
|
||||
}
|
||||
|
||||
export function createStdinReader(): Reader {
|
||||
const it = readline.createInterface({
|
||||
input: process.stdin
|
||||
})[Symbol.asyncIterator]()
|
||||
|
||||
return async () => (await it.next()).value
|
||||
}
|
||||
1
packages/js/src/readers/reader.ts
Normal file
1
packages/js/src/readers/reader.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type Reader = () => string|null|Promise<string|null>
|
||||
Reference in New Issue
Block a user