///|
priv struct ParseState {
rows : Array[DataLine]
options : ParseOptions
mut cursor : Int32
mut blocks : Int32
mut records : Int32
}
///|
fn label_record(row : DataLine) -> Record raise JcampError {
let text = trim(strip_comment(row.text))
guard text.has_prefix("##") else {
reject("LABEL_PREFIX", "expected ## label", line=row.line)
}
guard text.find("=") is Some(index) && index >= 3 else {
reject(
"LABEL_EQUALS",
"label requires a nonempty name and =",
line=row.line,
)
}
let label = trim(text[2:index].to_owned())
guard canonical_label(label) != "" &&
label.iter().all(c => c >= ' ' && c != '\u007f' && c != '#') else {
reject("LABEL_NAME", "invalid label name", line=row.line)
}
{ label, value: trim(text[index + 1:].to_owned()), line: row.line, }
}
///|
fn table_label(key : String) -> Bool {
key == "XYDATA" ||
key == "XYPOINTS" ||
key == "XYPAIRS" ||
key == "PEAKTABLE" ||
key == "DATATABLE"
}
///|
fn read_block(state : ParseState, depth : Int32) -> Block raise JcampError {
let first = label_record(state.rows[state.cursor])
guard canonical_label(first.label) == "TITLE" else {
reject("BLOCK_TITLE", "every block must begin with TITLE", line=first.line)
}
guard depth <= state.options.limits.max_depth else {
reject("LIMIT_DEPTH", "compound nesting budget exceeded", line=first.line)
}
state.blocks += 1
guard state.blocks <= state.options.limits.max_blocks else {
reject("LIMIT_BLOCKS", "block budget exceeded", line=first.line)
}
state.records += 1
let records = [first]
let tables : Array[DataTable] = []
let children : Array[Block] = []
let mut active_table : DataTable? = None
state.cursor += 1
while state.cursor < state.rows.length() {
let row = state.rows[state.cursor]
let text = trim(strip_comment(row.text))
if text == "" {
state.cursor += 1
continue
}
if text.has_prefix("##") {
let record = label_record(row)
let key = canonical_label(record.label)
if key == "TITLE" {
guard record_value(records, "DATA TYPE") is Some(kind) &&
canonical_label(kind) == "LINK" else {
reject(
"NESTED_BLOCK",
"nested TITLE is only permitted inside a LINK block",
line=row.line,
)
}
guard tables.is_empty() else {
reject(
"LINK_TABLE",
"LINK blocks cannot contain a numeric table",
line=row.line,
)
}
children.push(read_block(state, depth + 1))
active_table = None
continue
}
state.records += 1
guard state.records <= state.options.limits.max_records else {
reject("LIMIT_RECORDS", "record budget exceeded", line=row.line)
}
if key == "END" {
guard record.value == "" else {
reject("END_VALUE", "END must not have a value", line=row.line)
}
state.cursor += 1
return {
records,
tables,
children,
start_line: first.line,
end_line: row.line,
}
}
if table_label(key) {
let table : DataTable = {
label: record.label,
descriptor: record.value,
rows: [],
line: row.line,
}
tables.push(table)
active_table = Some(table)
} else {
records.push(record)
active_table = None
}
state.cursor += 1
continue
}
match active_table {
Some(table) => table.rows.push({ text, line: row.line, })
None => {
guard !records.is_empty() && children.is_empty() else {
reject(
"ORPHAN_TEXT",
"text is not attached to a label or data table",
line=row.line,
)
}
let index = records.length() - 1
let previous = records[index]
records[index] = { ..previous, value: previous.value + "\n" + text, }
}
}
state.cursor += 1
}
reject("END_MISSING", "block has no closing END", line=first.line)
}
///|
pub fn parse_document(
text : String,
options~ : ParseOptions = ParseOptions::default(),
) -> Document raise JcampError {
let rows = physical_lines(text, options)
let state : ParseState = { rows, options, cursor: 0, blocks: 0, records: 0, }
let blocks : Array[Block] = []
while state.cursor < rows.length() {
let row = rows[state.cursor]
if trim(strip_comment(row.text)) == "" {
state.cursor += 1
continue
}
blocks.push(read_block(state, 1))
}
guard !blocks.is_empty() else {
reject("DOCUMENT_EMPTY", "document contains no JCAMP blocks")
}
{ blocks, }
}
///|
pub fn block_title(block : Block) -> String {
record_value(block.records, "TITLE").unwrap_or("")
}
///|
pub fn block_kind(block : Block) -> String {
record_value(block.records, "DATA TYPE").unwrap_or("")
}
///|
pub fn document_block_count(
document : Document,
max_depth~ : Int32 = 16,
) -> Int32 raise JcampError {
guard max_depth > 0 && max_depth <= 128 else {
reject("OPTION_DEPTH", "invalid maximum depth")
}
fn count(blocks : Array[Block], depth : Int32) -> Int32 raise JcampError {
guard depth <= max_depth else {
reject("LIMIT_DEPTH", "document nesting budget exceeded")
}
let mut total = blocks.length()
for block in blocks {
if !block.children.is_empty() {
let nested = count(block.children, depth + 1)
guard nested <= 1000000 - total else {
reject("LIMIT_BLOCKS", "document block count exceeds safety budget")
}
total += nested
}
}
total
}
count(document.blocks, 1)
}