Files
overleaf-cep/services/web/shared/bibtex/parser/bibtex-stream-parser.mts
T
Eric Mc Sween 0ec99c7e3e Merge pull request #37028 from overleaf/em-bibtex-stream-parser
[web] add a streaming bibtex parser

GitOrigin-RevId: 5397ba546a3d5b3436a80eab0467244969b280d0
2026-09-17 12:08:28 +00:00

793 lines
21 KiB
TypeScript

import OError from '@overleaf/o-error'
import { BibtexEntry } from '../bibtex-entry.mts'
import {
BibtexFieldValue,
BibtexFieldValueBuilder,
} from '../bibtex-field-value.mts'
import {
AT,
CARRIAGE_RETURN,
CLOSE_BRACE,
CLOSE_PAREN,
COMMA,
EQUALS,
HASH,
LINE_FEED,
OPEN_BRACE,
OPEN_PAREN,
PERCENT,
QUOTE,
isBibtexIdentifierCharCode,
isBibtexIdentifierStartCharCode,
isDigitCode,
isWhitespaceCode,
} from './bibtex-char-codes.mts'
export type BibtexEntryItem = {
kind: 'entry'
entry: BibtexEntry
}
/** A `@string` definition, which the parser records rather than expands. */
export type BibtexNamedStringItem = {
kind: 'namedString'
/**
* Kept as written, as are the references to it. Names are matched
* case-insensitively, so a consumer resolving references has to fold case
* when it builds its lookup table — but lowercasing here would rewrite the
* author's own spelling of the name whenever an entry is read and written
* back, which entry types and field names do not have to worry about.
*
* A definition also takes effect only from where it appears, replacing any
* earlier one from that point on, so resolution has to follow item order.
*/
name: string
value: BibtexFieldValue
}
/** A `%` comment or a `@comment` body, without its delimiters. */
export type BibtexCommentItem = {
kind: 'comment'
text: string
}
/** Free-form text between entries, which BibTeX ignores. */
export type BibtexJunkItem = {
kind: 'junk'
text: string
}
export type BibtexItem =
| BibtexEntryItem
| BibtexNamedStringItem
| BibtexCommentItem
| BibtexJunkItem
/*
* Parser states. Those up to and including AFTER_VALUE sit between entry
* tokens, where whitespace and %-comments are skipped; the main loop tests for
* them with a single comparison, so their order matters. The IN_* states are
* mid-token, and are the only ones a chunk boundary can fall inside.
*/
const TOP = 0
const EXPECT_TYPE = 1
const EXPECT_BODY_OPEN = 2
const EXPECT_KEY = 3
const AFTER_KEY = 4
const EXPECT_FIELD_NAME = 5
const EXPECT_EQUALS = 6
const EXPECT_VALUE = 7
const AFTER_VALUE = 8
const LAST_INTER_TOKEN_STATE = AFTER_VALUE
const IN_TYPE = 9
const IN_KEY = 10
const IN_FIELD_NAME = 11
const IN_NUMBER = 12
const IN_NAME = 13
const IN_LITERAL = 14
const IN_COMMENT_BODY = 15
const IN_JUNK_WORD = 16
const IN_JUNK_SPACE = 17
const IN_LINE_COMMENT = 18
/** What an entry's body holds, selected by its type. */
const REGULAR = 0 // fields, introduced by a citation key
const NAMED_STRING = 1 // @string: named string definitions
const PREAMBLE = 2 // @preamble: a single value
const COMMENT = 3 // @comment: a literal
/** Mirrors specializeEntryType in the editor's bibtex grammar. */
function bodyKindOf(lowercasedType: string): number {
switch (lowercasedType) {
case 'string':
return NAMED_STRING
case 'preamble':
return PREAMBLE
case 'comment':
return COMMENT
default:
return REGULAR
}
}
/**
* Cap on the input one item may span. Streaming only keeps memory down while
* items keep completing: an unterminated literal, a single enormous word of
* junk, or an entry with endless fields would otherwise buffer the whole rest of
* the input. The limit is far above any real entry, comment or run of junk, so
* reaching it means the input is not a .bib file.
*/
const MAX_ITEM_LENGTH = 1 << 20
/**
* Thrown when one item spans more than MAX_ITEM_LENGTH characters of input. It
* leaves the parser part-way through that item, so the parser has to be
* discarded rather than written to again.
*/
export class BibtexItemTooLargeError extends OError {}
/**
* Chunk-oblivious BibTeX parser, built to index .bib files too large to hold a
* syntax tree for. It follows the same grammar as the editor's lezer parser
* (frontend/js/features/source-editor/lezer-bibtex/bibtex.grammar) and reads
* every entry the same way, so a reader sees the same references the editor
* shows them. The differential test in the library module pins that.
*
* Items come out in the order they occur, with one exception: a comment written
* inside an entry is reported before the entry that contains it, because the
* entry is only complete at its closing delimiter.
*
* Entries sharing a citation key are all reported, leaving it to the caller to
* decide which one wins. `@preamble` bodies are the only content dropped.
*
* An item spanning more than MAX_ITEM_LENGTH characters of input throws
* BibtexItemTooLargeError, so that no input can buffer without bound.
*/
export class BibtexStreamParser {
private state = TOP
private commentReturnState = TOP
/**
* Token text so far, which a chunk boundary may have cut in half. Only one
* token is ever in flight: every state that can start a comment or a run of
* junk is reached with the buffer already emptied.
*/
private token = ''
/** Whitespace inside a run of junk, held back until a word follows it. */
private pendingSpace = ''
/**
* Input consumed towards the item being read, against MAX_ITEM_LENGTH.
* Everything the parser holds on to between chunks — the token in flight, the
* entry's type, key and fields, the parts of the value being built — was read
* from this stretch of input, so bounding it bounds all of them.
*/
private consumed = 0
private bodyKind = REGULAR
/** Closing delimiter the entry body's opener calls for. */
private bodyTerminator = CLOSE_BRACE
private entryType: string | null = null
private entryKey: string | null = null
private fields = new Map<string, BibtexFieldValue>()
private fieldName: string | null = null
/**
* The parts of the value being read. An empty builder separates "foo" and
* "foo =", which define nothing — biber rejects both as syntax errors — from
* "foo = {}", which defines the empty string and so adds a part.
*/
private fieldValue = new BibtexFieldValueBuilder()
/** Closing delimiter of the literal being scanned, and its brace nesting. */
private literalTerminator = CLOSE_BRACE
private literalDepth = 0
private items: BibtexItem[] = []
/** Parses a chunk, returning the items it completed. */
write(chunk: string): BibtexItem[] {
let i = 0
while (i < chunk.length) {
const state = this.state
if (state <= LAST_INTER_TOKEN_STATE) {
const code = chunk.charCodeAt(i)
if (isWhitespaceCode(code)) {
// Whitespace between tokens is dropped, so it costs no memory and
// goes uncounted: a file can hold as much of it as it likes.
i += 1
continue
}
if (code === PERCENT) {
this.commentReturnState = state
this.state = IN_LINE_COMMENT
i += 1
continue
}
}
const start = i
switch (state) {
case TOP:
i = this.parseTop(chunk, i)
break
case EXPECT_TYPE:
i = this.parseExpectType(chunk, i)
break
case EXPECT_BODY_OPEN:
i = this.parseExpectBodyOpen(chunk, i)
break
case EXPECT_KEY:
i = this.parseExpectKey(chunk, i)
break
case AFTER_KEY:
i = this.parseAfterKey(chunk, i)
break
case EXPECT_FIELD_NAME:
i = this.parseExpectFieldName(chunk, i)
break
case EXPECT_EQUALS:
i = this.parseExpectEquals(chunk, i)
break
case EXPECT_VALUE:
i = this.parseExpectValue(chunk, i)
break
case AFTER_VALUE:
i = this.parseAfterValue(chunk, i)
break
case IN_TYPE:
i = this.scanType(chunk, i)
break
case IN_KEY:
i = this.scanKey(chunk, i)
break
case IN_FIELD_NAME:
i = this.scanFieldName(chunk, i)
break
case IN_NUMBER:
i = this.scanNumber(chunk, i)
break
case IN_NAME:
i = this.scanName(chunk, i)
break
case IN_LITERAL:
i = this.scanValueLiteral(chunk, i)
break
case IN_COMMENT_BODY:
i = this.scanCommentBody(chunk, i)
break
case IN_JUNK_WORD:
i = this.scanJunkWord(chunk, i)
break
case IN_JUNK_SPACE:
i = this.scanJunkSpace(chunk, i)
break
case IN_LINE_COMMENT:
i = this.scanLineComment(chunk, i)
break
}
this.consume(i - start)
}
return this.takeItems()
}
/**
* Signals the end of the input, returning any final items. An entry left
* unterminated still yields what was parsed of it, as it does in the editor.
*/
end(): BibtexItem[] {
switch (this.state) {
case IN_TYPE:
this.finishType()
break
case IN_KEY:
this.finishKey()
break
case IN_FIELD_NAME:
this.finishFieldName()
break
case IN_NUMBER:
this.finishNumber()
break
case IN_NAME:
this.finishName()
break
case IN_LITERAL:
this.finishLiteral()
break
case IN_COMMENT_BODY:
this.emitComment()
break
case IN_JUNK_WORD:
case IN_JUNK_SPACE:
this.emitJunk()
break
case IN_LINE_COMMENT:
this.emitComment()
break
}
this.endEntry()
this.state = TOP
this.consumed = 0
return this.takeItems()
}
private parseTop(chunk: string, i: number): number {
if (chunk.charCodeAt(i) === AT) {
this.beginEntry()
this.state = EXPECT_TYPE
return i + 1
}
this.state = IN_JUNK_WORD
return i
}
/**
* Scans a word of free-form comment text. Where the word ends decides how the
* next "%" reads: it only opens a comment at the start of a word, so
* "Junk% @article{a}" holds an entry while "Junk % @article{a}" has it
* commented out.
*/
private scanJunkWord(chunk: string, i: number): number {
const start = i
while (i < chunk.length) {
const code = chunk.charCodeAt(i)
if (code === AT) {
this.token += chunk.slice(start, i)
this.emitJunk()
this.state = TOP
return i
}
if (isWhitespaceCode(code)) {
this.token += chunk.slice(start, i)
this.state = IN_JUNK_SPACE
return i
}
i += 1
}
this.token += chunk.slice(start, i)
return i
}
private scanJunkSpace(chunk: string, i: number): number {
const start = i
while (i < chunk.length && isWhitespaceCode(chunk.charCodeAt(i))) {
i += 1
}
this.pendingSpace += chunk.slice(start, i)
if (i === chunk.length) {
return i
}
const code = chunk.charCodeAt(i)
if (code === AT || code === PERCENT) {
this.emitJunk()
this.state = TOP
return i
}
this.token += this.pendingSpace
this.pendingSpace = ''
this.state = IN_JUNK_WORD
return i
}
private scanLineComment(chunk: string, i: number): number {
const start = i
while (i < chunk.length) {
const code = chunk.charCodeAt(i)
if (code === LINE_FEED || code === CARRIAGE_RETURN) {
break
}
i += 1
}
this.token += chunk.slice(start, i)
if (i < chunk.length) {
this.emitComment()
this.state = this.commentReturnState
}
return i
}
private parseExpectType(chunk: string, i: number): number {
if (isBibtexIdentifierStartCharCode(chunk.charCodeAt(i))) {
this.state = IN_TYPE
return i
}
return this.recover(i)
}
private scanType(chunk: string, i: number): number {
i = this.scanIdentifier(chunk, i)
if (i < chunk.length) {
this.finishType()
}
return i
}
private finishType() {
const type = this.takeToken().toLowerCase()
this.entryType = type
this.bodyKind = bodyKindOf(type)
this.state = EXPECT_BODY_OPEN
}
private parseExpectBodyOpen(chunk: string, i: number): number {
const code = chunk.charCodeAt(i)
if (code !== OPEN_BRACE && code !== OPEN_PAREN) {
return this.recover(i)
}
this.bodyTerminator = code === OPEN_BRACE ? CLOSE_BRACE : CLOSE_PAREN
switch (this.bodyKind) {
case COMMENT:
// A @comment body is a literal rather than a list of values, so it
// swallows any entry written inside it.
this.beginLiteral(this.bodyTerminator)
this.state = IN_COMMENT_BODY
break
case PREAMBLE:
this.state = EXPECT_VALUE
break
case NAMED_STRING:
this.state = EXPECT_FIELD_NAME
break
default:
this.state = EXPECT_KEY
}
return i + 1
}
private parseExpectKey(chunk: string, i: number): number {
if (isBibtexIdentifierCharCode(chunk.charCodeAt(i))) {
this.state = IN_KEY
return i
}
return this.recover(i)
}
private scanKey(chunk: string, i: number): number {
i = this.scanIdentifier(chunk, i)
if (i < chunk.length) {
this.finishKey()
}
return i
}
private finishKey() {
// Citation keys keep their case, unlike types and field names.
this.entryKey = this.takeToken()
this.state = AFTER_KEY
}
private parseAfterKey(chunk: string, i: number): number {
const code = chunk.charCodeAt(i)
if (code === COMMA) {
this.state = EXPECT_FIELD_NAME
return i + 1
}
if (code === this.bodyTerminator) {
return this.closeBody(i)
}
return this.recover(i)
}
private parseExpectFieldName(chunk: string, i: number): number {
const code = chunk.charCodeAt(i)
if (code === this.bodyTerminator) {
return this.closeBody(i)
}
if (isBibtexIdentifierStartCharCode(code)) {
this.state = IN_FIELD_NAME
return i
}
return this.recover(i)
}
private scanFieldName(chunk: string, i: number): number {
i = this.scanIdentifier(chunk, i)
if (i < chunk.length) {
this.finishFieldName()
}
return i
}
private finishFieldName() {
this.fieldName = this.takeToken()
this.state = EXPECT_EQUALS
}
private parseExpectEquals(chunk: string, i: number): number {
const code = chunk.charCodeAt(i)
if (code === EQUALS) {
this.state = EXPECT_VALUE
return i + 1
}
if (code === this.bodyTerminator) {
return this.closeBody(i)
}
return this.recover(i)
}
private parseExpectValue(chunk: string, i: number): number {
const code = chunk.charCodeAt(i)
if (code === OPEN_BRACE || code === QUOTE) {
this.beginLiteral(code === OPEN_BRACE ? CLOSE_BRACE : QUOTE)
this.state = IN_LITERAL
return i + 1
}
if (isDigitCode(code)) {
this.state = IN_NUMBER
return i
}
if (isBibtexIdentifierStartCharCode(code)) {
this.state = IN_NAME
return i
}
// A missing value contributes no part, so "title=," and "title={x} #}"
// behave as if the value had already ended here.
this.state = AFTER_VALUE
return i
}
private scanNumber(chunk: string, i: number): number {
const start = i
while (i < chunk.length && isDigitCode(chunk.charCodeAt(i))) {
i += 1
}
this.token += chunk.slice(start, i)
if (i < chunk.length) {
this.finishNumber()
}
return i
}
private finishNumber() {
this.fieldValue.addNumber(this.takeToken())
this.state = AFTER_VALUE
}
private scanName(chunk: string, i: number): number {
i = this.scanIdentifier(chunk, i)
if (i < chunk.length) {
this.finishName()
}
return i
}
private finishName() {
// A reference to a named string is recorded, not expanded.
this.fieldValue.addNamedString(this.takeToken())
this.state = AFTER_VALUE
}
private scanValueLiteral(chunk: string, i: number): number {
i = this.scanLiteral(chunk, i)
if (i < chunk.length) {
this.finishLiteral()
return i + 1
}
return i
}
private finishLiteral() {
// A line break and the indentation following it collapse to one space.
this.fieldValue.addString(this.takeToken().replaceAll(/[\n\r]\s*/g, ' '))
this.state = AFTER_VALUE
}
private scanCommentBody(chunk: string, i: number): number {
i = this.scanLiteral(chunk, i)
if (i < chunk.length) {
this.emitComment()
this.endEntry()
this.state = TOP
return i + 1
}
return i
}
private parseAfterValue(chunk: string, i: number): number {
const code = chunk.charCodeAt(i)
if (code === HASH) {
this.state = EXPECT_VALUE
return i + 1
}
if (code === COMMA) {
this.commitField()
this.state = EXPECT_FIELD_NAME
return i + 1
}
if (code === this.bodyTerminator) {
return this.closeBody(i)
}
return this.recover(i)
}
/** Appends identifier characters to the token buffer. */
private scanIdentifier(chunk: string, i: number): number {
const start = i
while (
i < chunk.length &&
isBibtexIdentifierCharCode(chunk.charCodeAt(i))
) {
i += 1
}
this.token += chunk.slice(start, i)
return i
}
private beginLiteral(terminator: number) {
this.literalTerminator = terminator
this.literalDepth = 0
}
/**
* Appends literal text to the token buffer, stopping at its closing
* delimiter. Only braces nest, and there are no backslash escapes: every
* other delimiter is content until the nesting is back to zero.
*/
private scanLiteral(chunk: string, i: number): number {
const start = i
while (i < chunk.length) {
const code = chunk.charCodeAt(i)
if (code === OPEN_BRACE) {
this.literalDepth += 1
} else if (code === CLOSE_BRACE && this.literalDepth > 0) {
this.literalDepth -= 1
} else if (this.literalDepth === 0 && code === this.literalTerminator) {
break
}
i += 1
}
this.token += chunk.slice(start, i)
return i
}
/**
* Counts the input a step of the main loop read towards the item in progress.
* Landing back at TOP means the item is complete and the parser is holding
* nothing, so the count starts over from there.
*/
private consume(length: number) {
if (this.state === TOP) {
this.consumed = 0
return
}
this.consumed += length
if (this.consumed > MAX_ITEM_LENGTH) {
throw new BibtexItemTooLargeError('bibtex item is too large', {
limit: MAX_ITEM_LENGTH,
})
}
}
private takeToken(): string {
const token = this.token
this.token = ''
return token
}
private emitJunk() {
const text = this.takeToken()
this.pendingSpace = ''
if (text !== '') {
this.items.push({ kind: 'junk', text })
}
}
private emitComment() {
const text = this.takeToken()
// A comment can sit inside an entry, whose fields are still held, so only
// the comment's own share of the input is given back.
this.consumed -= text.length
this.items.push({ kind: 'comment', text })
}
private beginEntry() {
this.bodyKind = REGULAR
this.entryType = null
this.entryKey = null
this.fields = new Map()
this.fieldName = null
this.fieldValue = new BibtexFieldValueBuilder()
this.token = ''
}
private commitField() {
if (this.fieldName != null) {
if (this.bodyKind === NAMED_STRING) {
// "@string{foo}" and "@string{foo =}" define nothing, so reporting them
// would let either shadow an earlier valid definition of foo.
if (!this.fieldValue.isEmpty()) {
this.items.push({
kind: 'namedString',
name: this.fieldName,
value: this.fieldValue.build(),
})
}
} else {
// A field with no value is still committed, to match the editor.
this.fields.set(this.fieldName.toLowerCase(), this.fieldValue.build())
}
}
this.fieldName = null
this.fieldValue = new BibtexFieldValueBuilder()
}
private closeBody(i: number): number {
this.endEntry()
this.state = TOP
return i + 1
}
/**
* Ends the entry where it stands after an unexpected character, keeping the
* fields parsed so far. Leaving the character for the top level to look at
* again is what lets an unclosed entry hand the next "@" to a fresh one.
*/
private recover(i: number): number {
this.endEntry()
this.state = TOP
return i
}
/**
* Completes the entry in progress, reporting it if it has both a type and a
* citation key.
*/
private endEntry() {
this.commitField()
if (
this.bodyKind === REGULAR &&
this.entryType != null &&
this.entryKey != null
) {
this.items.push({
kind: 'entry',
entry: new BibtexEntry({
type: this.entryType,
key: this.entryKey,
fields: this.fields,
}),
})
}
this.entryType = null
this.entryKey = null
this.fields = new Map()
this.token = ''
}
private takeItems(): BibtexItem[] {
const items = this.items
this.items = []
return items
}
}
/** Parses a whole .bib file held in memory. */
export function parseBibtexItems(text: string): BibtexItem[] {
const parser = new BibtexStreamParser()
const items = parser.write(text)
return items.concat(parser.end())
}
/**
* Parses a .bib file arriving in chunks. The caller is responsible for decoding
* bytes with a stateful decoder, so a multi-byte character split across chunks
* is not corrupted.
*/
export async function* streamBibtexItems(
chunks: AsyncIterable<string>
): AsyncGenerator<BibtexItem> {
const parser = new BibtexStreamParser()
for await (const chunk of chunks) {
yield* parser.write(chunk)
}
yield* parser.end()
}