// What every string-level reader (CSV / JSON / NDJSON) shares, kept in one
// place so the formats cannot drift apart: BOM handling (historically CSV
// stripped the BOM while JSON / NDJSON did not, so a file saved by Excel read
// cleanly through one format and failed through the others), and the message
// spelling of a typed source coordinate — the prefix every reader renders a
// `Row` / `Line` / `Record` location with.
//
// One-file-one-test exemption: this shared file has no dedicated test file
// of its own. (doc-guard: unresolved) It is covered through the per-format
// BOM tests and the parse-error message tests in the CSV / JSON / NDJSON
// suites.
///|
/// Strip a single leading UTF-8 BOM (`U+FEFF`) if present, returning the rest
/// of the content unchanged.
///
/// Excel and many Windows tools prepend a BOM when saving `utf-8-sig`. Left in
/// place it leaks into the first column's name (CSV) or makes `@json.parse`
/// reject the payload at column 0 (JSON / NDJSON). Matches Polars / pandas,
/// which strip it on read for every format. A BOM-only file collapses to the
/// empty string, so callers that short-circuit empty input still see "no
/// payload".
fn strip_utf8_bom(content : String) -> String {
if content.view().get_char(0) == Some('\u{FEFF}') {
content.view().view(start_offset=1).to_owned()
} else {
content
}
}
///|
/// The message-level spelling of a typed source coordinate — the prefix
/// every reader renders a `Row` / `Line` / `Record` location with, single
/// home for the row / line / record vocabulary, so a hand-spelled prefix
/// cannot drift from the typed one.
fn locator_text(loc : @types.CellParseLocation, n : Int) -> String {
match loc {
Row => "row \{n}"
Line => "line \{n}"
Record => "record \{n}"
}
}
// ── duplicate-key scan (JSON family, `reject_duplicate_keys`) ───────────
//
// `@json.parse` folds an object's keys into a `Map`, so a repeated key
// survives only as its last value — the serde / pandas behaviour, and the
// default here. `reject_duplicate_keys` offers the strict reading, and the
// only place a repeat is still visible is the raw text, so the check is a
// small lexer over the payload that runs *before* the parse and only when
// the option is on: the default read pays nothing. It is a lexer, not a
// parser — well-formedness stays `@json.parse`'s business, so every
// out-of-shape input is tolerated here and answered by the parser's own
// diagnostic; the scan only tracks enough state to know which string
// literals are object keys (a string directly after `{` or after `,`
// inside an object) and compares them decoded, so `"a"` and `"\u0061"`
// count as the same key exactly as they do after parsing.
///|
/// One nesting level of the duplicate-key scan: an object's seen keys plus
/// whether the next string literal is a key, or an array's item level.
priv enum JsonScanFrame {
ObjectKeys(Map[String, Unit], Bool)
ArrayItems
}
///|
/// The first repeated key in a JSON payload, as `(1-based object number,
/// key)`, or `None` when no object repeats a key. Objects are counted in
/// textual order across the whole payload, nested objects included, so the
/// number names the object the repeat was found in. String literals are
/// decoded (`\uXXXX` included) before comparison, matching what the keys
/// become after `@json.parse`; escape-level differences that decode to the
/// same key are therefore caught, never the reverse. Malformed input is
/// tolerated — the parser behind it reports it, and nothing here should
/// pre-empt a different diagnostic.
fn first_duplicate_json_key(content : StringView) -> (Int, String)? {
// Collect once: the `\uXXXX` look-ahead needs index arithmetic an
// iterator will not give. Code units, exactly as `write_char` re-emits
// them, so a literal surrogate pair and its `\uD83D\uDE00` spelling
// normalise to the same sequence without pairing work.
let chars : Array[Char] = []
for ch in content.iter() {
chars.push(ch)
}
let stack : Array[JsonScanFrame] = []
let mut objects = 0
let n = chars.length()
let mut i = 0
while i < n {
let c = chars[i]
if c == '"' {
let (token, next) = read_json_string_token(chars, i)
// Only a string in key position is recorded; elsewhere it is a value.
if stack.length() > 0 {
match stack[stack.length() - 1] {
ObjectKeys(seen, true) => {
if seen.contains(token) {
return Some((objects, token))
}
seen[token] = ()
stack[stack.length() - 1] = ObjectKeys(seen, false)
}
_ => ()
}
}
i = next
continue
}
match c {
'{' => {
objects = objects + 1
stack.push(ObjectKeys(Map([]), true))
}
'[' => stack.push(ArrayItems)
'}' | ']' =>
// A stray closer pops nothing; the parser reports the shape.
if stack.length() > 0 {
let _ = stack.pop()
}
',' =>
if stack.length() > 0 {
match stack[stack.length() - 1] {
// The string after a comma inside an object is the next key.
ObjectKeys(seen, _) =>
stack[stack.length() - 1] = ObjectKeys(seen, true)
ArrayItems => ()
}
}
_ => () // whitespace, literals, digits, and ':' carry no key state
}
i = i + 1
}
None
}
///|
/// Read one JSON string literal starting at the opening quote `chars[start]`,
/// returning its decoded content and the index just past the closing quote.
/// Escapes decode to the code-unit sequence `write_char` would re-emit
/// (`\uXXXX` to its code unit — a surrogate pair spelled out stays two code
/// units, matching how a literal one reads back), so two spellings of the
/// same key compare equal. An unterminated literal or malformed escape
/// yields what was read so far and stops the scan: `@json.parse` owns that
/// diagnostic.
fn read_json_string_token(chars : Array[Char], start : Int) -> (String, Int) {
let buf = StringBuilder()
let n = chars.length()
let mut i = start + 1
while i < n {
let c = chars[i]
if c == '"' {
return (buf.to_string(), i + 1)
}
if c != '\\' {
buf.write_char(c)
i = i + 1
continue
}
// An escape with nothing behind it cannot be finished; stop the token.
if i + 1 >= n {
break
}
match chars[i + 1] {
'"' => {
buf.write_char('"')
i = i + 2
}
'\\' => {
buf.write_char('\\')
i = i + 2
}
'/' => {
buf.write_char('/')
i = i + 2
}
'b' => {
buf.write_char('\b')
i = i + 2
}
'f' => {
buf.write_char('\u{000C}')
i = i + 2
}
'n' => {
buf.write_char('\n')
i = i + 2
}
'r' => {
buf.write_char('\r')
i = i + 2
}
't' => {
buf.write_char('\t')
i = i + 2
}
'u' =>
match hex4(chars, i + 2) {
// A short or non-hex escape is the parser's to report; stop here.
None => {
i = n
break
}
Some(unit) => {
buf.write_char(unit.unsafe_to_char())
i = i + 6
}
}
// An unknown escape is not JSON; hand the payload to the parser.
_ => {
i = n
break
}
}
}
(buf.to_string(), n)
}
///|
/// The 4-hex-digit value at `chars[at..at+4]`, or `None` when fewer than four
/// characters remain or any of them is not a hex digit.
fn hex4(chars : Array[Char], at : Int) -> Int? {
if at + 4 > chars.length() {
return None
}
let mut value = 0
for k in 0..<4 {
let d = match chars[at + k] {
c if c >= '0' && c <= '9' => c.to_int() - '0'.to_int()
c if c >= 'a' && c <= 'f' => c.to_int() - 'a'.to_int() + 10
c if c >= 'A' && c <= 'F' => c.to_int() - 'A'.to_int() + 10
_ => return None
}
value = value * 16 + d
}
Some(value)
}