All files / json-viewer/src/model path.ts

95.13% Statements 137/144
91.83% Branches 90/98
100% Functions 10/10
96.87% Lines 124/128

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296                                  4x                         11x   3x   2x   6x           300x 300x 1360x 1360x 578x 782x 422x   360x     300x         14635x 14635x 26002x   14635x                       449x 449x 2045x 929x 1116x 668x   448x     449x         448x 448x 2927x   52x 52x   21x 21x   1x 1x   1x 1x   4x 4x   1x 1x   68x 68x   2779x 2779x             448x                               22x 12x 22x 12x     16x                     587x 3x 1x   2x     584x 584x   584x 284x 283x     583x   583x 2701x   2701x 567x 567x 3152x 567x 566x 566x 566x     2134x 1856x 1856x 1856x 704x 704x 704x 4647x 4288x 4288x 4288x         359x 359x 356x 356x   701x 700x 700x 700x 700x   1152x 1152x 1152x 1151x 1151x 1151x 1150x   1850x 1850x     278x 278x 1892x 278x 278x 278x           576x       4x                                           359x 359x   358x 3x 3x 2x     355x 355x                   273x 273x 1326x 1326x   1326x   1326x 1326x         273x    
/**
 * Path formatting and parsing (SPEC.md §4.5).
 *
 * Three formats, all round-trippable, because "copy path" means different
 * things in different places: a JS accessor to paste into a console, an RFC
 * 6901 pointer to paste into a patch or a config, a JSONPath to paste into a
 * query tool.
 *
 * The correctness of this module is checked against an **independent**
 * implementation in the tests (`jsonpath-plus`, plus a resolver written from
 * the RFC text) — our own tree agreeing with our own formatter would only
 * prove the two halves are consistent, not that either is right.
 */
 
import type { JsonPath, PathFormat, PathSegment } from "../types";
 
/** A bare `.key` is only legal for an identifier-shaped key. */
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
 
/**
 * Render a path in one of the supported formats.
 *
 * @example
 * ```ts
 * formatPath(["users", 0, "first name"], "js");        // users[0]["first name"]
 * formatPath(["users", 0, "first name"], "pointer");   // /users/0/first name
 * formatPath(["users", 0, "first name"], "jsonpath");  // $.users[0]['first name']
 * ```
 */
export function formatPath(path: JsonPath, format: PathFormat = "js"): string {
  switch (format) {
    case "pointer":
      return pathToPointer(path);
    case "jsonpath":
      return pathToJsonPath(path);
    default:
      return pathToAccessor(path);
  }
}
 
/** `users[0]["first name"]` — pasteable into a console against the value. */
export function pathToAccessor(path: JsonPath): string {
  let out = "";
  for (let i = 0; i < path.length; i++) {
    const segment = path[i]!;
    if (typeof segment === "number") {
      out += `[${segment}]`;
    } else if (IDENTIFIER.test(segment)) {
      out += i === 0 ? segment : `.${segment}`;
    } else {
      out += `[${JSON.stringify(segment)}]`;
    }
  }
  return out;
}
 
/** `/users/0/first name` — RFC 6901. The root is the empty string. */
export function pathToPointer(path: JsonPath): string {
  let out = "";
  for (const segment of path) {
    out += `/${String(segment).replace(/~/g, "~0").replace(/\//g, "~1")}`;
  }
  return out;
}
 
/**
 * `$.users[0]['first name']` — JSONPath, escaped per RFC 9535 §2.3.1.
 *
 * Single-quoted name selectors escape `\` and `'`, and control characters are
 * not allowed literally. Getting this right matters more than it looks: an
 * unescaped apostrophe silently terminates the selector, so the query still
 * parses and quietly returns the wrong node.
 */
export function pathToJsonPath(path: JsonPath): string {
  let out = "$";
  for (const segment of path) {
    if (typeof segment === "number") {
      out += `[${segment}]`;
    } else if (IDENTIFIER.test(segment)) {
      out += `.${segment}`;
    } else {
      out += `['${escapeJsonPathName(segment)}']`;
    }
  }
  return out;
}
 
/** RFC 9535 §2.3.1.1 escaping for the contents of a single-quoted name. */
function escapeJsonPathName(name: string): string {
  let out = "";
  for (const char of name) {
    switch (char) {
      case "\\":
        out += "\\\\";
        break;
      case "'":
        out += "\\'";
        break;
      case "\b":
        out += "\\b";
        break;
      case "\f":
        out += "\\f";
        break;
      case "\n":
        out += "\\n";
        break;
      case "\r":
        out += "\\r";
        break;
      case "\t":
        out += "\\t";
        break;
      default: {
        const code = char.codePointAt(0)!;
        out +=
          code < 0x20 || code === 0x7f
            ? `\\u${code.toString(16).padStart(4, "0")}`
            : char;
      }
    }
  }
  return out;
}
 
/**
 * Split an RFC 6901 pointer into its raw segments.
 *
 * Segments stay strings: whether `"0"` means an array index or an object key
 * is a property of the container it lands in, and only the tree knows that.
 *
 * @example
 * ```ts
 * pointerToSegments("/a~1b/0");   // ["a/b", "0"]
 * pointerToSegments("");          // []
 * ```
 */
export function pointerToSegments(pointer: string): string[] {
  if (pointer === "" || pointer === "#") return [];
  const body = pointer.startsWith("#") ? pointer.slice(1) : pointer;
  Iif (!body.startsWith("/")) return [];
  return body
    .slice(1)
    .split("/")
    .map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~"));
}
 
/**
 * Parse a formatted path back into segments.
 *
 * Numeric bracket subscripts come back as numbers and quoted ones as strings,
 * so `parsePath(formatPath(p)) === p` for every path this package emits.
 * Returns `null` when the text is not a valid path in `format`.
 */
export function parsePath(text: string, format: PathFormat = "js"): PathSegment[] | null {
  if (format === "pointer") {
    if (text !== "" && text !== "#" && !text.replace(/^#/, "").startsWith("/")) {
      return null;
    }
    return pointerToSegments(text);
  }
 
  let cursor = 0;
  const segments: PathSegment[] = [];
 
  if (format === "jsonpath") {
    if (text[0] !== "$") return null;
    cursor = 1;
  }
 
  let first = format !== "jsonpath";
 
  while (cursor < text.length) {
    const char = text[cursor];
 
    if (char === ".") {
      cursor++;
      const start = cursor;
      while (cursor < text.length && /[A-Za-z0-9_$]/.test(text[cursor]!)) cursor++;
      if (cursor === start) return null;
      segments.push(text.slice(start, cursor));
      first = false;
      continue;
    }
 
    if (char === "[") {
      cursor++;
      const quote = text[cursor];
      if (quote === '"' || quote === "'") {
        cursor++;
        let value = "";
        while (cursor < text.length && text[cursor] !== quote) {
          if (text[cursor] !== "\\") {
            value += text[cursor];
            cursor++;
            continue;
          }
          // An escape sequence carries meaning: taking the next character
          // literally turns the key "a\nb" into "anb", which is a different
          // key that happens to look plausible.
          const decoded = decodeEscape(text, cursor);
          if (!decoded) return null;
          value += decoded.char;
          cursor = decoded.next;
        }
        if (text[cursor] !== quote) return null;
        cursor++;
        Iif (text[cursor] !== "]") return null;
        cursor++;
        segments.push(value);
      } else {
        const start = cursor;
        while (cursor < text.length && text[cursor] !== "]") cursor++;
        if (text[cursor] !== "]") return null;
        const raw = text.slice(start, cursor);
        cursor++;
        if (!/^\d+$/.test(raw)) return null;
        segments.push(Number(raw));
      }
      first = false;
      continue;
    }
 
    Eif (first && /[A-Za-z_$]/.test(char!)) {
      const start = cursor;
      while (cursor < text.length && /[A-Za-z0-9_$]/.test(text[cursor]!)) cursor++;
      segments.push(text.slice(start, cursor));
      first = false;
      continue;
    }
 
    return null;
  }
 
  return segments;
}
 
/** The single-character escapes shared by JSON strings and RFC 9535 names. */
const SIMPLE_ESCAPES: Record<string, string> = {
  '"': '"',
  "'": "'",
  "\\": "\\",
  "/": "/",
  b: "\b",
  f: "\f",
  n: "\n",
  r: "\r",
  t: "\t",
};
 
/**
 * Decode the escape sequence starting at `text[at]` (which must be `\`).
 *
 * @returns the decoded character and the index just past the sequence, or
 * `null` when the sequence is malformed.
 */
function decodeEscape(
  text: string,
  at: number,
): { char: string; next: number } | null {
  const marker = text[at + 1];
  if (marker === undefined) return null;
 
  if (marker === "u") {
    const hex = text.slice(at + 2, at + 6);
    if (!/^[0-9a-fA-F]{4}$/.test(hex)) return null;
    return { char: String.fromCharCode(parseInt(hex, 16)), next: at + 6 };
  }
 
  const simple = SIMPLE_ESCAPES[marker];
  return simple === undefined ? null : { char: simple, next: at + 2 };
}
 
/**
 * Resolve a path against a value, for tests and for consumers of `./headless`.
 *
 * Mirrors how the tree addresses children: `Map` and `Set` positionally,
 * everything else by key or index.
 */
export function resolvePath(data: unknown, path: JsonPath): unknown {
  let current: unknown = data;
  for (const segment of path) {
    Iif (current === null || current === undefined) return undefined;
    Iif (current instanceof Map) {
      current = Array.from(current.values())[Number(segment)];
    I} else if (current instanceof Set) {
      current = Array.from(current.values())[Number(segment)];
    } else if (typeof current === "object") {
      current = (current as Record<PathSegment, unknown>)[segment as never];
    } else E{
      return undefined;
    }
  }
  return current;
}