blob: 7c5aedb1b592e70f9aff8fc325984d0bcaa8169c [file]
export type JsonRecordClassification = 'complete' | 'incomplete-prefix' | 'invalid';
/** Classifies whether bytes missing only from the end could make this valid JSON. */
export function classifyJsonRecord(value: string): JsonRecordClassification {
try {
JSON.parse(value);
return 'complete';
} catch {
// Only parse failures need the stricter prefix distinction below.
}
return new JsonPrefixParser(value).classify();
}
class JsonPrefixParser {
private index = 0;
constructor(private readonly value: string) {}
classify(): JsonRecordClassification {
this.skipWhitespace();
const state = this.parseValue(0);
if (state !== 'complete') return state;
this.skipWhitespace();
return this.index === this.value.length ? 'complete' : 'invalid';
}
private parseValue(depth: number): JsonRecordClassification {
if (depth > 512) return 'invalid';
if (this.index === this.value.length) return 'incomplete-prefix';
switch (this.value[this.index]) {
case '{':
return this.parseObject(depth + 1);
case '[':
return this.parseArray(depth + 1);
case '"':
return this.parseString();
case 't':
return this.parseLiteral('true');
case 'f':
return this.parseLiteral('false');
case 'n':
return this.parseLiteral('null');
default:
return this.parseNumber();
}
}
private parseObject(depth: number): JsonRecordClassification {
this.index += 1;
this.skipWhitespace();
if (this.index === this.value.length) return 'incomplete-prefix';
if (this.value[this.index] === '}') {
this.index += 1;
return 'complete';
}
while (true) {
if (this.value[this.index] !== '"') return 'invalid';
const key = this.parseString();
if (key !== 'complete') return key;
this.skipWhitespace();
if (this.index === this.value.length) return 'incomplete-prefix';
if (this.value[this.index] !== ':') return 'invalid';
this.index += 1;
this.skipWhitespace();
const item = this.parseValue(depth);
if (item !== 'complete') return item;
this.skipWhitespace();
if (this.index === this.value.length) return 'incomplete-prefix';
const delimiter = this.value[this.index];
if (delimiter === '}') {
this.index += 1;
return 'complete';
}
if (delimiter !== ',') return 'invalid';
this.index += 1;
this.skipWhitespace();
if (this.index === this.value.length) return 'incomplete-prefix';
if (this.value[this.index] === '}') return 'invalid';
}
}
private parseArray(depth: number): JsonRecordClassification {
this.index += 1;
this.skipWhitespace();
if (this.index === this.value.length) return 'incomplete-prefix';
if (this.value[this.index] === ']') {
this.index += 1;
return 'complete';
}
while (true) {
const item = this.parseValue(depth);
if (item !== 'complete') return item;
this.skipWhitespace();
if (this.index === this.value.length) return 'incomplete-prefix';
const delimiter = this.value[this.index];
if (delimiter === ']') {
this.index += 1;
return 'complete';
}
if (delimiter !== ',') return 'invalid';
this.index += 1;
this.skipWhitespace();
if (this.index === this.value.length) return 'incomplete-prefix';
if (this.value[this.index] === ']') return 'invalid';
}
}
private parseString(): JsonRecordClassification {
this.index += 1;
while (this.index < this.value.length) {
const code = this.value.charCodeAt(this.index);
const char = this.value[this.index]!;
this.index += 1;
if (char === '"') return 'complete';
if (code < 0x20) return 'invalid';
if (char !== '\\') continue;
if (this.index === this.value.length) return 'incomplete-prefix';
const escape = this.value[this.index]!;
this.index += 1;
if ('"\\/bfnrt'.includes(escape)) continue;
if (escape !== 'u') return 'invalid';
const remaining = this.value.length - this.index;
if (remaining < 4) {
const suffix = this.value.slice(this.index);
return /^[0-9a-fA-F]*$/.test(suffix) ? 'incomplete-prefix' : 'invalid';
}
if (!/^[0-9a-fA-F]{4}$/.test(this.value.slice(this.index, this.index + 4))) {
return 'invalid';
}
this.index += 4;
}
return 'incomplete-prefix';
}
private parseLiteral(literal: string): JsonRecordClassification {
const available = this.value.slice(this.index, this.index + literal.length);
if (!literal.startsWith(available)) return 'invalid';
if (available.length < literal.length) {
this.index = this.value.length;
return 'incomplete-prefix';
}
this.index += literal.length;
return 'complete';
}
private parseNumber(): JsonRecordClassification {
const start = this.index;
if (this.value[this.index] === '-') {
this.index += 1;
if (this.index === this.value.length) return 'incomplete-prefix';
}
const first = this.value[this.index];
if (first === '0') {
this.index += 1;
if (isDigit(this.value[this.index])) return 'invalid';
} else if (isNonZeroDigit(first)) {
this.index += 1;
while (isDigit(this.value[this.index])) this.index += 1;
} else {
this.index = start;
return 'invalid';
}
if (this.value[this.index] === '.') {
this.index += 1;
if (this.index === this.value.length) return 'incomplete-prefix';
if (!isDigit(this.value[this.index])) return 'invalid';
while (isDigit(this.value[this.index])) this.index += 1;
}
if (this.value[this.index] === 'e' || this.value[this.index] === 'E') {
this.index += 1;
if (this.index === this.value.length) return 'incomplete-prefix';
if (this.value[this.index] === '+' || this.value[this.index] === '-') {
this.index += 1;
if (this.index === this.value.length) return 'incomplete-prefix';
}
if (!isDigit(this.value[this.index])) return 'invalid';
while (isDigit(this.value[this.index])) this.index += 1;
}
return 'complete';
}
private skipWhitespace(): void {
while (
this.value[this.index] === ' ' ||
this.value[this.index] === '\t' ||
this.value[this.index] === '\n' ||
this.value[this.index] === '\r'
) {
this.index += 1;
}
}
}
function isDigit(value: string | undefined): boolean {
return value !== undefined && value >= '0' && value <= '9';
}
function isNonZeroDigit(value: string | undefined): boolean {
return value !== undefined && value >= '1' && value <= '9';
}