3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-12 19:58:28 +01:00

Answer principles round 1: 1 🔴, 5 🟡 (0 misread); the document carry's line feeds checked, element spellings shared with the writer, carried JSON bounded by type, an edit to a carry refused, one test per case, a start tag read one attribute at a time

This commit is contained in:
2026-10-28 01:27:40 +01:00
committed by Lillem4n
parent e39ca86d74
commit 0cb80fd4fc
14 changed files with 430 additions and 302 deletions
+47 -19
View File
@@ -1,6 +1,6 @@
import type { AdfNode } from '../../adf/document.ts'
import type { ConvertErrorCode, ConvertErrorPath, ParseError, Result, SourcePosition } from '../../result.ts'
import { unescapedValue } from '../escaping.ts'
import { misspelledAt, unescapedValue } from '../escaping.ts'
// A node's place among its parent's content, the document's own content where `parent` is undefined; a path is built only for a refusal.
export type Place = { index: number; parent: Place | undefined }
@@ -10,29 +10,39 @@ export type ReadNode = Result<{ end: number; node: AdfNode }, ParseError>
// A start tag as `adfToHtml` writes it, its attributes' values unescaped.
export type StartTag = { attributes: (readonly [string, string])[]; end: number; name: string; start: number }
const attribute = / ([a-z][a-z0-9-]*)="([^"]*)"/g
const lineEnding = /\r\n?|\n/g
const startTag = /<([a-z][a-z0-9]*)((?: [a-z][a-z0-9-]*="[^"]*")*)>/y
// A tag whose name reads, its attributes or its `>` not: `stopped` is where reading them stopped.
type CutTag = { name: string; stopped: number }
const attribute = / ([a-z][a-z0-9-]*)="([^"]*)"/y
const invisible = /(?! )[\p{Cf}\p{Z}\u007f-\u009f]/gu
const tagName = /<([a-z][a-z0-9]*)/y
// `undefined` where no start tag stands at `index`, or one of its values is not escaped as the dialect escapes it.
export function readStartTag(html: string, index: number): StartTag | undefined {
startTag.lastIndex = index
const match = startTag.exec(html)
if (match === null) return undefined
const attributes: (readonly [string, string])[] = []
for (const [, name = '', spelled = ''] of (match[2] ?? '').matchAll(attribute)) {
const value = unescapedValue(spelled)
if (value === undefined) return undefined
attributes.push([name, value])
}
return { attributes, end: index + match[0].length, name: match[1] ?? '', start: index }
const tag = scanStartTag(html, index)
return tag !== undefined && 'end' in tag ? tag : undefined
}
// What stands at `index`, for a refusal's message.
// The name of the start tag at `index`, read whatever its attributes hold.
export function startTagName(html: string, index: number): string | undefined {
tagName.lastIndex = index
return tagName.exec(html)?.[1]
}
// Why reading stopped at `index` short of `expected`, and where: a tag the caller takes none of is `unexpected`.
export function misread(html: string, index: number, expected: string, unexpected: string): { at: number; message: string } {
const tag = scanStartTag(html, index)
if (tag === undefined) return { at: index, message: `${expected}: ${foundAt(html, index)}` }
if ('end' in tag) return { at: index, message: `<${tag.name}> as written here is ${unexpected}: ${foundAt(html, index)}` }
return { at: tag.stopped, message: `<${tag.name}> holds its attributes as name="value", each value escaped as text is, then >: ${foundAt(html, tag.stopped)}` }
}
// What stands at `index`, for a refusal's message, a character no one sees spelled as its escape.
export function foundAt(html: string, index: number): string {
if (index >= html.length) return 'found the end of the HTML'
const found = html.slice(index, index + 24)
return `found ${JSON.stringify(found)}${index + found.length < html.length ? '…' : ''}`
const spelled = JSON.stringify(found).replace(invisible, (character) => Array.from({ length: character.length }, (_, unit) => `\\u${character.charCodeAt(unit).toString(16).padStart(4, '0')}`).join(''))
return `found ${spelled}${index + found.length < html.length ? '…' : ''}`
}
// At the start of the line `index` stands on.
@@ -40,13 +50,31 @@ export function refused<T>(code: ConvertErrorCode, message: string, html: string
return { error: { code, message, path: pathOf(place), position: lineStart(html, index) }, ok: false }
}
// A line ends at a line feed, a carriage return or both, as an HTML parser reads it.
// One attribute a match, since a pattern repeating over them all overflows V8's regular expression stack on a long tag.
function scanStartTag(html: string, index: number): CutTag | StartTag | undefined {
const name = startTagName(html, index)
if (name === undefined) return undefined
const attributes: (readonly [string, string])[] = []
let at = index + 1 + name.length
for (;;) {
attribute.lastIndex = at
const match = attribute.exec(html)
if (match === null) return html.startsWith('>', at) ? { attributes, end: at + 1, name, start: index } : { name, stopped: at }
const [whole, key = '', spelled = ''] = match
const value = unescapedValue(spelled)
if (value === undefined) return { name, stopped: at + whole.length - 1 - spelled.length + misspelledAt(spelled) }
attributes.push([key, value])
at += whole.length
}
}
// Only a line feed ends a line: the reader refuses a carriage return wherever it stands.
function lineStart(html: string, index: number): SourcePosition {
let line = 1
let offset = 0
for (const ending of html.slice(0, index).matchAll(lineEnding)) {
for (let next = html.indexOf('\n'); next >= 0 && next < index; next = html.indexOf('\n', next + 1)) {
line += 1
offset = ending.index + ending[0].length
offset = next + 1
}
return { line, offset }
}