harn-stdlib 0.10.34

Embedded Harn standard library source catalog
Documentation
// std/jsonl - bounded JSON Lines readers and simple writers.
import { FileLine, FileLinePageOptions, FsFailure, read_lines_page_result } from "std/fs"
import { SchemaContract, ValidationIssue, schema_contract_check } from "std/schema"

pub type ReadJsonlOptions = {strict?: bool, max_records?: int, max_bytes?: int, on_error?: any}

type ResolvedReadJsonlOptions = {strict: bool, max_records: int, max_bytes: int, on_error?: any}

pub type JsonlCursor = {offset: int, line: int}

pub type JsonlPageOptions = {cursor?: JsonlCursor, max_records?: int, max_bytes?: int}

pub type JsonlRecord<T> = {line: int, offset: int, raw: string, value: T}

pub type JsonlRecordFailureKind = "malformed" | "schema_invalid" | "rule_failed" | "rule_error"

pub type JsonlRecordFailure = {
  kind: JsonlRecordFailureKind,
  line: int,
  offset: int,
  raw: string,
  detail: string,
  issues?: list<ValidationIssue>,
}

pub type JsonlPage<T> = {
  records: list<JsonlRecord<T>>,
  issues: list<JsonlRecordFailure>,
  next_cursor: JsonlCursor,
  done: bool,
}

pub type JsonlPageResult<T> = Result<JsonlPage<T>, FsFailure>

fn __read_options(options) -> ResolvedReadJsonlOptions {
  const opts = options ?? {}
  return {
    strict: opts.strict ?? false,
    max_records: to_int(opts.max_records) ?? 0,
    max_bytes: to_int(opts.max_bytes) ?? 1048576,
    on_error: opts.on_error,
  }
}

fn __page_options(options: JsonlPageOptions) -> FileLinePageOptions {
  const opts = options ?? {}
  const cursor = opts.cursor ?? {offset: 0, line: 1}
  return {
    offset: cursor.offset,
    line: cursor.line,
    max_lines: opts.max_records ?? 256,
    max_bytes: opts.max_bytes ?? 1048576,
  }
}

fn __malformed(line: FileLine, failure) -> JsonlRecordFailure {
  return {
    kind: "malformed",
    line: line.line,
    offset: line.offset,
    raw: line.text,
    detail: failure?.message ?? to_string(failure),
  }
}

fn __parse_line(line: FileLine) -> Result<JsonlRecord<unknown>?, JsonlRecordFailure> {
  if trim(line.text) == "" {
    return Ok(nil)
  }
  const parsed = try {
    json_parse(line.text)
  }
  if !is_ok(parsed) {
    return Err(__malformed(line, unwrap_err(parsed)))
  }
  return Ok({line: line.line, offset: line.offset, raw: line.text, value: unwrap(parsed)})
}

/**
 * Read one bounded page of JSONL records and preserve malformed rows as issues.
 *
 * Resume with `page.next_cursor`. Each page holds at most `max_records`
 * physical lines and `max_bytes` source bytes. Blank lines advance the cursor
 * without producing records.
 *
 * @effects: [fs.read]
 * @errors: []
 */
pub fn read_jsonl_page_result(
  path: string,
  options: JsonlPageOptions = {},
) -> JsonlPageResult<unknown> {
  const source = read_lines_page_result(path, __page_options(options))
  if !is_ok(source) {
    return Err(unwrap_err(source))
  }
  const lines = unwrap(source)
  let records: list<JsonlRecord<unknown>> = []
  let issues: list<JsonlRecordFailure> = []
  for line in lines.lines {
    const parsed = __parse_line(line)
    if !is_ok(parsed) {
      issues = issues + [unwrap_err(parsed)]
    } else if unwrap(parsed) != nil {
      records = records + [unwrap(parsed)]
    }
  }
  return Ok(
    {
      records: records,
      issues: issues,
      next_cursor: {offset: lines.next_offset, line: lines.next_line},
      done: lines.done,
    },
  )
}

/**
 * Throwing form of `read_jsonl_page_result`.
 *
 * @effects: [fs.read]
 * @errors: [FsFailure]
 */
pub fn read_jsonl_page(path: string, options: JsonlPageOptions = {}) -> JsonlPage<unknown> {
  return unwrap(read_jsonl_page_result(path, options))
}

fn __contract_failure(record: JsonlRecord<unknown>, failure) -> JsonlRecordFailure {
  return {
    kind: failure.kind,
    line: record.line,
    offset: record.offset,
    raw: record.raw,
    detail: failure.detail,
    issues: failure.issues,
  }
}

/**
 * Read one bounded JSONL page and apply a structural schema plus named rules to
 * every valid JSON record.
 *
 * Malformed JSON, structural failures, rule violations, and broken rule
 * implementations remain distinct in `page.issues`.
 *
 * @effects: [fs.read]
 * @errors: []
 */
pub fn read_jsonl_contract_page_result<T>(
  path: string,
  contract: SchemaContract<T>,
  options: JsonlPageOptions = {},
) -> JsonlPageResult<T> {
  const raw = read_jsonl_page_result(path, options)
  if !is_ok(raw) {
    return Err(unwrap_err(raw))
  }
  const page = unwrap(raw)
  let records: list<JsonlRecord<T>> = []
  let issues = page.issues
  for record in page.records {
    const checked = schema_contract_check(record.value, contract)
    if !is_ok(checked) {
      issues = issues + [__contract_failure(record, unwrap_err(checked))]
      continue
    }
    records = records
      + [
      {line: record.line, offset: record.offset, raw: record.raw, value: unwrap(checked)},
    ]
  }
  return Ok({records: records, issues: issues, next_cursor: page.next_cursor, done: page.done})
}

/**
 * Throwing form of `read_jsonl_contract_page_result`.
 *
 * @effects: [fs.read]
 * @errors: [FsFailure]
 */
pub fn read_jsonl_contract_page<T>(
  path: string,
  contract: SchemaContract<T>,
  options: JsonlPageOptions = {},
) -> JsonlPage<T> {
  return unwrap(read_jsonl_contract_page_result(path, contract, options))
}

/**
 * Parse an in-memory JSONL string. Use `read_jsonl_page_result` for files that
 * may grow beyond a safe in-memory size.
 *
 * @effects: []
 * @errors: [JsonlRecordFailure]
 */
pub fn parse_jsonl(text: string, options: ReadJsonlOptions = {}) -> list {
  return fold_jsonl(text, [], fn(records, record, _index) { return records + [record] }, options)
}

/**
 * Materialize a JSONL file for compatibility with list-oriented callers.
 *
 * The file is still read in bounded pages. Prefer `read_jsonl_page_result` or
 * `fold_jsonl_file` when the complete result need not reside in memory.
 *
 * @effects: [fs.read]
 * @errors: [FsFailure, JsonlRecordFailure]
 */
pub fn read_jsonl(path: string, options: ReadJsonlOptions = {}) -> list {
  const opts = __read_options(options)
  let out = []
  let cursor: JsonlCursor = {offset: 0, line: 1}
  while true {
    const remaining: int = if opts.max_records > 0 {
      opts.max_records - len(out)
    } else {
      256
    }
    if remaining <= 0 {
      break
    }
    const page_records: int = min(256, remaining)
    const page_options: JsonlPageOptions = {
      cursor: cursor,
      max_records: page_records,
      max_bytes: opts.max_bytes,
    }
    const page = read_jsonl_page_result(path, page_options)
    if !is_ok(page) {
      if opts.strict {
        throw unwrap_err(page)
      }
      return out
    }
    const value = unwrap(page)
    for issue in value.issues {
      if opts.strict {
        throw issue
      }
      if opts.on_error != nil {
        opts.on_error(issue.raw, issue)
      }
    }
    for record in value.records {
      out = out + [record.value]
    }
    cursor = value.next_cursor
    if value.done {
      break
    }
  }
  return out
}

fn __serialize(items: list) -> string {
  let lines = []
  for item in items ?? [] {
    lines = lines + [json_stringify(item)]
  }
  return join(lines, "\n") + "\n"
}

/**
 * Replace a JSONL file and return the number of records written.
 *
 * @effects: [fs.write]
 * @errors: [FsFailure]
 */
pub fn write_jsonl(path: string, items: list) -> int {
  harness.fs.write_text(path, __serialize(items))
  return len(items ?? [])
}

/**
 * Append one JSONL record and return the byte count including its newline.
 *
 * @effects: [fs.write]
 * @errors: [FsFailure]
 */
pub fn append_jsonl(path: string, item) -> int {
  const line = json_stringify(item) + "\n"
  harness.fs.append(path, line)
  return len(line)
}

/**
 * Fold already-loaded JSONL content without materializing parsed records.
 *
 * @effects: []
 * @errors: [JsonlRecordFailure]
 */
pub fn fold_jsonl(text: string, initial, reducer, options: ReadJsonlOptions = {}) -> any {
  const opts = __read_options(options)
  let state = initial
  let index = 0
  let line_number = 1
  let offset = 0
  for text_line in split(text ?? "", "\n") {
    if opts.max_records > 0 && index >= opts.max_records {
      break
    }
    const line: FileLine = {line: line_number, offset: offset, text: text_line}
    const parsed = __parse_line(line)
    if !is_ok(parsed) {
      const failure = unwrap_err(parsed)
      if opts.strict {
        throw failure
      }
      if opts.on_error != nil {
        opts.on_error(line.text, failure)
      }
    } else if unwrap(parsed) != nil {
      state = reducer(state, unwrap(parsed).value, index)
      index = index + 1
    }
    line_number = line_number + 1
    offset = offset + len(text_line) + 1
  }
  return state
}

/**
 * Fold a JSONL file through bounded pages.
 *
 * @effects: [fs.read]
 * @errors: [FsFailure, JsonlRecordFailure]
 */
pub fn fold_jsonl_file(path: string, initial, reducer, options: ReadJsonlOptions = {}) -> any {
  const opts = __read_options(options)
  let state = initial
  let index = 0
  let cursor: JsonlCursor = {offset: 0, line: 1}
  while true {
    const page_options: JsonlPageOptions = {
      cursor: cursor,
      max_records: 256,
      max_bytes: opts.max_bytes,
    }
    const page = read_jsonl_page_result(path, page_options)
    if !is_ok(page) {
      throw unwrap_err(page)
    }
    const value = unwrap(page)
    for issue in value.issues {
      if opts.strict {
        throw issue
      }
      if opts.on_error != nil {
        opts.on_error(issue.raw, issue)
      }
    }
    for record in value.records {
      if opts.max_records > 0 && index >= opts.max_records {
        return state
      }
      state = reducer(state, record.value, index)
      index = index + 1
    }
    cursor = value.next_cursor
    if value.done {
      return state
    }
  }
}