a textual notation to locate fields within atproto records (draft spec) microcosm.tngl.io/RecordPath/
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

nice errors

phil 8aac0a78 2faf139d

+142 -25
+34 -9
playground/src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { enumerate, match, type PathInfo } from 'recordpath'; 2 + import { enumerate, match, parse, RecordPathParseError, type PathInfo } from 'recordpath'; 3 3 4 4 const SAMPLE = { 5 5 $type: 'app.bsky.feed.post', ··· 66 66 let record = $derived(parseResult.record); 67 67 let jsonError = $derived(parseResult.error); 68 68 let paths: PathInfo[] = $derived(record ? enumerate(record) : []); 69 - let matches: unknown[] = $derived( 70 - record && pathInput.trim() ? match(record, pathInput.trim()) : [] 71 - ); 69 + 70 + let pathResult = $derived.by(() => { 71 + const trimmed = pathInput.trim(); 72 + if (!trimmed || !record) { 73 + return { matches: [] as unknown[], parseError: null as RecordPathParseError | null }; 74 + } 75 + try { 76 + parse(trimmed); 77 + return { matches: match(record, trimmed), parseError: null }; 78 + } catch (e) { 79 + if (e instanceof RecordPathParseError) { 80 + return { matches: [], parseError: e }; 81 + } 82 + return { matches: [], parseError: null }; 83 + } 84 + }); 85 + 86 + let matches = $derived(pathResult.matches); 87 + let parseError = $derived(pathResult.parseError); 72 88 let hasInput = $derived(pathInput.trim().length > 0); 73 - let noMatch = $derived(hasInput && record !== null && matches.length === 0); 89 + let noMatch = $derived(hasInput && record !== null && !parseError && matches.length === 0); 74 90 75 91 function selectPath(path: string) { 76 92 pathInput = path; ··· 119 135 <!-- Main panels --> 120 136 <main class="grid flex-1 grid-cols-2 overflow-hidden"> 121 137 <!-- Left: JSON editor --> 122 - <div class="flex flex-col overflow-hidden border-r border-stone-200"> 138 + <div class="flex flex-col flex-2 overflow-hidden border-r border-stone-200"> 123 139 <div 124 140 class="border-b border-stone-200 bg-white px-3 py-1.5 text-[0.6875rem] font-semibold tracking-wide text-stone-400 uppercase" 125 141 > ··· 140 156 </div> 141 157 142 158 <!-- Right: paths + matches --> 143 - <div class="flex flex-col overflow-hidden"> 159 + <div class="flex flex-col flex-1 overflow-hidden"> 144 160 <!-- Path input --> 145 161 <div class="border-b border-stone-200 bg-white p-2"> 146 162 <input ··· 150 166 spellcheck="false" 151 167 autocomplete="off" 152 168 class="w-full rounded border-[1.5px] bg-white px-2.5 py-2 font-mono text-sm outline-none 153 - {noMatch 169 + {parseError 154 170 ? 'border-red-400 shadow-[0_0_0_2px_theme(colors.red.100)]' 155 - : 'border-stone-200 focus:border-blue-500 focus:shadow-[0_0_0_2px_theme(colors.blue.100)]'}" 171 + : noMatch 172 + ? 'border-amber-400 shadow-[0_0_0_2px_theme(colors.amber.100)]' 173 + : 'border-stone-200 focus:border-blue-500 focus:shadow-[0_0_0_2px_theme(colors.blue.100)]'}" 156 174 /> 175 + {#if parseError} 176 + <pre 177 + class="mt-1.5 overflow-x-auto rounded bg-red-50 px-2.5 py-2 font-mono text-[0.8125rem]" 178 + ><span class="text-red-300">{pathInput.trim()}</span> 179 + <span class="text-red-500">{' '.repeat(parseError.position)}^ {parseError.message.split(' (position')[0]}</span>{#if parseError.suggestion} 180 + <span class="text-emerald-500">hint: {parseError.suggestionHint}:</span> <button class="cursor-pointer text-emerald-600 underline decoration-emerald-300 hover:decoration-emerald-500" onclick={() => { pathInput = parseError.suggestion ?? ''; }}>{parseError.suggestion}</button>{/if}</pre> 181 + {/if} 157 182 </div> 158 183 159 184 <!-- Path list header -->
+108 -16
ref-impl-js/src/index.ts
··· 3 3 4 4 const STRUCTURAL = new Set(['.', '[', ']', '{', '}', '!']); 5 5 6 + /** Represents a parse error in a RecordPath string */ 7 + export class RecordPathParseError extends Error { 8 + /** the input string that failed to parse */ 9 + readonly input: string; 10 + /** character position where the error was detected */ 11 + readonly position: number; 12 + /** a corrected version of the input, if one can be inferred */ 13 + readonly suggestion: string | null; 14 + /** explanation of what the suggestion changes */ 15 + readonly suggestionHint: string | null; 16 + 17 + constructor( 18 + message: string, 19 + input: string, 20 + position: number, 21 + suggestion?: string, 22 + suggestionHint?: string 23 + ) { 24 + super(`${message} (position ${position} in '${input}')`); 25 + this.name = 'RecordPathParseError'; 26 + this.input = input; 27 + this.position = position; 28 + this.suggestion = suggestion ?? null; 29 + this.suggestionHint = suggestionHint ?? null; 30 + } 31 + } 32 + 6 33 export interface Qualifier { 7 34 type: 'array' | 'arrayUnion' | 'scalarUnion'; 8 35 nsid?: string; ··· 27 54 return out; 28 55 } 29 56 30 - // Parse a RecordPath string into segments. 31 - // TODO: error returns?? (or throws) 32 57 export function parse(str: string): Segment[] { 58 + if (str === '') throw new RecordPathParseError('empty path', str, 0); 59 + 33 60 const segments: Segment[] = []; 34 61 let i = 0; 35 62 36 63 while (i < str.length) { 37 64 let key = ''; 38 65 39 - // TODO: does this permit misplaced unescaped close chars? `]` and `}` 40 - // we should probably fail/reject those. 41 66 while (i < str.length && str[i] !== '.' && str[i] !== '[' && str[i] !== '{') { 42 - if (str[i] === '!' && i + 1 < str.length) { 43 - key += str[i + 1]; 67 + if (str[i] === '!') { 68 + if (i + 1 >= str.length) { 69 + throw new RecordPathParseError( 70 + 'escape at end of input', 71 + str, 72 + i, 73 + str.slice(0, i) + '!!', 74 + "escape the '!' as '!!'" 75 + ); 76 + } 77 + const next = str[i + 1]; 78 + if (!STRUCTURAL.has(next)) { 79 + throw new RecordPathParseError( 80 + `escape followed by non-escapable '${next}'`, 81 + str, 82 + i, 83 + str.slice(0, i) + '!!' + str.slice(i + 1), 84 + "escape the '!' as '!!'" 85 + ); 86 + } 87 + key += next; 44 88 i += 2; 89 + } else if (str[i] === ']') { 90 + throw new RecordPathParseError( 91 + "unexpected ']' without opening '['", 92 + str, 93 + i, 94 + str.slice(0, i) + '!]' + str.slice(i + 1), 95 + "escape as '!]'" 96 + ); 97 + } else if (str[i] === '}') { 98 + throw new RecordPathParseError( 99 + "unexpected '}' without opening '{'", 100 + str, 101 + i, 102 + str.slice(0, i) + '!}' + str.slice(i + 1), 103 + "escape as '!}'" 104 + ); 45 105 } else { 46 106 key += str[i]; 47 107 i++; 48 108 } 49 109 } 50 110 111 + if (key === '') { 112 + throw new RecordPathParseError('empty segment', str, i); 113 + } 114 + 51 115 const qualifiers: Qualifier[] = []; 52 116 while (i < str.length && (str[i] === '[' || str[i] === '{')) { 53 117 const open = str[i]; 54 118 const close = open === '[' ? ']' : '}'; 119 + const openPos = i; 55 120 i++; 56 121 let content = ''; 57 122 while (i < str.length && str[i] !== close) { 58 123 content += str[i]; 59 124 i++; 60 125 } 61 - if (i < str.length) i++; 126 + if (i >= str.length) { 127 + throw new RecordPathParseError( 128 + `unclosed '${open}'`, 129 + str, 130 + openPos, 131 + str + close, 132 + `close with '${close}'` 133 + ); 134 + } 135 + i++; // skip close 62 136 63 137 if (open === '[') { 64 138 qualifiers.push( ··· 70 144 } 71 145 72 146 segments.push({ key, qualifiers }); 73 - if (i < str.length && str[i] === '.') i++; 147 + 148 + if (i < str.length && str[i] === '.') { 149 + i++; 150 + if (i >= str.length) { 151 + throw new RecordPathParseError( 152 + 'trailing dot', 153 + str, 154 + i - 1, 155 + str.slice(0, -1), 156 + 'remove the trailing dot' 157 + ); 158 + } 159 + } 74 160 } 75 161 76 162 return segments; ··· 137 223 // Enumerate all RecordPaths reachable from a record. 138 224 export function enumerate(record: Record<string, unknown>): PathInfo[] { 139 225 const paths = new Map<string, PathInfo>(); 140 - enumObject(record, '', true, false, paths); 226 + enumObject(record, '', false, paths); 141 227 return Array.from(paths.values()); 142 228 } 143 229 144 230 function enumObject( 145 231 obj: Record<string, unknown>, 146 232 prefix: string, 147 - isRoot: boolean, 148 233 isVector: boolean, 149 234 paths: Map<string, PathInfo> 150 235 ) { ··· 159 244 } else if (Array.isArray(child)) { 160 245 paths.set(keyPath, { path: keyPath, type: vtype }); 161 246 enumArray(child, keyPath, isVector, paths); 162 - } else if ((child as Record<string, unknown>).$type && !isRoot) { 247 + } else if ((child as Record<string, unknown>).$type) { 163 248 paths.set(keyPath, { path: keyPath, type: vtype }); 164 249 const nsid = (child as Record<string, unknown>).$type as string; 165 250 const qualified = keyPath + '{' + nsid + '}'; 166 251 paths.set(qualified, { path: qualified, type: vtype }); 167 - enumObject(child as Record<string, unknown>, qualified, false, isVector, paths); 252 + enumObject(child as Record<string, unknown>, qualified, isVector, paths); 168 253 } else { 169 254 paths.set(keyPath, { path: keyPath, type: vtype }); 170 - enumObject(child as Record<string, unknown>, keyPath, false, isVector, paths); 255 + enumObject(child as Record<string, unknown>, keyPath, isVector, paths); 171 256 } 172 257 } 173 258 } ··· 206 291 const qp = prefix + '[' + nsid + ']'; 207 292 paths.set(qp, { path: qp, type: 'vector' }); 208 293 for (const el of elements) { 209 - enumObject(el, qp, false, true, paths); 294 + enumObject(el, qp, true, paths); 210 295 } 211 296 } 212 297 if (plain.length > 0) { ··· 225 310 } else if (Array.isArray(el)) { 226 311 enumArray(el, prefix, true, paths); 227 312 } else { 228 - enumObject(el as Record<string, unknown>, prefix, false, true, paths); 313 + enumObject(el as Record<string, unknown>, prefix, true, paths); 229 314 } 230 315 } 231 316 } ··· 235 320 // (or enumerate could be a generator?? that might be even nicer) 236 321 237 322 export function isVector(pathStr: string): boolean { 238 - return /\[/.test(pathStr); 323 + for (let i = 0; i < pathStr.length; i++) { 324 + if (pathStr[i] === '!' && i + 1 < pathStr.length) { 325 + i++; // skip escaped character 326 + } else if (pathStr[i] === '[') { 327 + return true; 328 + } 329 + } 330 + return false; 239 331 }