// RecordPath — parser, matcher, enumerator // Reference implementation of the RecordPath draft spec const STRUCTURAL = new Set(['.', '[', ']', '{', '}', '!']); /** Represents a parse error in a RecordPath string */ export class RecordPathParseError extends Error { /** the input string that failed to parse */ readonly input: string; /** character position where the error was detected */ readonly position: number; /** a corrected version of the input, if one can be inferred */ readonly suggestion: string | null; /** explanation of what the suggestion changes */ readonly suggestionHint: string | null; constructor( message: string, input: string, position: number, suggestion?: string, suggestionHint?: string ) { super(`${message} (position ${position} in '${input}')`); this.name = 'RecordPathParseError'; this.input = input; this.position = position; this.suggestion = suggestion ?? null; this.suggestionHint = suggestionHint ?? null; } } export interface Qualifier { type: 'array' | 'arrayUnion' | 'scalarUnion'; nsid?: string; } export interface Segment { key: string; qualifiers: Qualifier[]; } export interface PathInfo { path: string; type: 'scalar' | 'vector'; } export function escapeFieldName(key: string): string { let out = ''; for (const ch of key) { if (STRUCTURAL.has(ch)) out += '!' + ch; else out += ch; } return out; } export function parse(str: string): Segment[] { if (str === '') throw new RecordPathParseError('empty path', str, 0); const segments: Segment[] = []; let i = 0; while (i < str.length) { let key = ''; while (i < str.length && str[i] !== '.' && str[i] !== '[' && str[i] !== '{') { if (str[i] === '!') { if (i + 1 >= str.length) { throw new RecordPathParseError( 'escape at end of input', str, i, str.slice(0, i) + '!!', "escape the '!' as '!!'" ); } const next = str[i + 1]; if (!STRUCTURAL.has(next)) { throw new RecordPathParseError( `escape followed by non-escapable '${next}'`, str, i, str.slice(0, i) + '!!' + str.slice(i + 1), "escape the '!' as '!!'" ); } key += next; i += 2; } else if (str[i] === ']') { throw new RecordPathParseError( "unexpected ']' without opening '['", str, i, str.slice(0, i) + '!]' + str.slice(i + 1), "escape as '!]'" ); } else if (str[i] === '}') { throw new RecordPathParseError( "unexpected '}' without opening '{'", str, i, str.slice(0, i) + '!}' + str.slice(i + 1), "escape as '!}'" ); } else { key += str[i]; i++; } } if (key === '') { throw new RecordPathParseError('empty segment', str, i); } const qualifiers: Qualifier[] = []; while (i < str.length && (str[i] === '[' || str[i] === '{')) { const open = str[i]; const close = open === '[' ? ']' : '}'; const openPos = i; i++; let content = ''; while (i < str.length && str[i] !== close) { content += str[i]; i++; } if (i >= str.length) { throw new RecordPathParseError( `unclosed '${open}'`, str, openPos, str + close, `close with '${close}'` ); } i++; // skip close if (open === '[') { qualifiers.push( content === '' ? { type: 'array' } : { type: 'arrayUnion', nsid: content } ); } else { qualifiers.push({ type: 'scalarUnion', nsid: content }); } } segments.push({ key, qualifiers }); if (i < str.length && str[i] === '.') { i++; if (i >= str.length) { throw new RecordPathParseError( 'trailing dot', str, i - 1, str.slice(0, -1), 'remove the trailing dot' ); } } } return segments; } // Match a RecordPath against a record, returning all matched values. export function match(record: Record, pathStr: string): unknown[] { if (!pathStr || pathStr.trim() === '') return []; try { const segments = parse(pathStr); return matchSegments(record, segments, 0); } catch { return []; } } function matchSegments(data: unknown, segments: Segment[], segIdx: number): unknown[] { if (segIdx >= segments.length) return [data]; const seg = segments[segIdx]; if (typeof data !== 'object' || data === null || Array.isArray(data)) return []; const obj = data as Record; const value = obj[seg.key]; if (value === undefined) return []; return applyQualifiers(value, seg.qualifiers, 0, segments, segIdx); } function applyQualifiers( value: unknown, qualifiers: Qualifier[], qualIdx: number, segments: Segment[], segIdx: number ): unknown[] { if (qualIdx >= qualifiers.length) { if (segIdx + 1 >= segments.length) return [value]; return matchSegments(value, segments, segIdx + 1); } const qual = qualifiers[qualIdx]; if (qual.type === 'scalarUnion') { if (typeof value !== 'object' || value === null || Array.isArray(value)) return []; const obj = value as Record; if (obj.$type !== qual.nsid) return []; return applyQualifiers(value, qualifiers, qualIdx + 1, segments, segIdx); } if (qual.type === 'array' || qual.type === 'arrayUnion') { if (!Array.isArray(value)) return []; const results: unknown[] = []; for (const elem of value) { if (qual.type === 'arrayUnion') { if (typeof elem !== 'object' || elem === null) continue; if ((elem as Record).$type !== qual.nsid) continue; } results.push(...applyQualifiers(elem, qualifiers, qualIdx + 1, segments, segIdx)); } return results; } return []; } // Enumerate all RecordPaths reachable from a record. // Returns a generator yielding [PathInfo, value] pairs, deduplicated by path. export function* enumerate( record: Record ): Generator<[PathInfo, unknown]> { const seen = new Set(); yield* enumObject(seen, record, '', false); } function* enumObject( seen: Set, obj: Record, prefix: string, isVector: boolean ): Generator<[PathInfo, unknown]> { const vtype = isVector ? 'vector' : 'scalar'; for (const key of Object.keys(obj)) { const child = obj[key]; const escaped = escapeFieldName(key); const keyPath = prefix ? prefix + '.' + escaped : escaped; if (child === null || child === undefined || typeof child !== 'object') { if (!seen.has(keyPath)) { seen.add(keyPath); yield [{ path: keyPath, type: vtype }, child]; } } else if (Array.isArray(child)) { if (!seen.has(keyPath)) { seen.add(keyPath); yield [{ path: keyPath, type: vtype }, child]; } yield* enumArray(seen, child, keyPath); } else if ((child as Record).$type) { if (!seen.has(keyPath)) { seen.add(keyPath); yield [{ path: keyPath, type: vtype }, child]; } const nsid = (child as Record).$type as string; const qualified = keyPath + '{' + nsid + '}'; if (!seen.has(qualified)) { seen.add(qualified); yield [{ path: qualified, type: vtype }, child]; } yield* enumObject(seen, child as Record, qualified, isVector); } else { if (!seen.has(keyPath)) { seen.add(keyPath); yield [{ path: keyPath, type: vtype }, child]; } yield* enumObject(seen, child as Record, keyPath, isVector); } } } function* enumArray( seen: Set, arr: unknown[], prefix: string ): Generator<[PathInfo, unknown]> { const hasUnion = arr.some( (el) => typeof el === 'object' && el !== null && !Array.isArray(el) && (el as Record).$type ); if (hasUnion) { let hasPlain = false; for (const el of arr) { const nsid = typeof el === 'object' && el !== null && !Array.isArray(el) ? ((el as Record).$type as string | undefined) : undefined; if (nsid) { const qp = prefix + '[' + nsid + ']'; if (!seen.has(qp)) { seen.add(qp); yield [{ path: qp, type: 'vector' }, el]; } if (typeof el === 'object' && el !== null && !Array.isArray(el)) { yield* enumObject(seen, el as Record, qp, true); } } else { hasPlain = true; yield* enumValue(seen, el, prefix + '[]'); } } if (hasPlain) { const bare = prefix + '[]'; if (!seen.has(bare)) { seen.add(bare); yield [{ path: bare, type: 'vector' }, arr]; } } } else { const bare = prefix + '[]'; if (!seen.has(bare)) { seen.add(bare); yield [{ path: bare, type: 'vector' }, arr]; } for (const el of arr) { yield* enumValue(seen, el, bare); } } } function* enumValue( seen: Set, value: unknown, prefix: string ): Generator<[PathInfo, unknown]> { if (value === null || value === undefined || typeof value !== 'object') { return; } if (Array.isArray(value)) { yield* enumArray(seen, value, prefix); } else { yield* enumObject(seen, value as Record, prefix, true); } } export function isVector(pathStr: string): boolean { for (let i = 0; i < pathStr.length; i++) { if (pathStr[i] === '!' && i + 1 < pathStr.length) { i++; // skip escaped character } else if (pathStr[i] === '[') { return true; } } return false; }