jpath-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 takeaseatventure
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,139 @@
1
+ # jpath-cli
2
+
3
+ **Query and extract data from JSON with a simple, powerful path syntax.** A focused, zero-dependency CLI — `jq`'s little sibling for the 90% case.
4
+
5
+ [![tests](https://img.shields.io/badge/tests-40%20pass-brightgreen)]() [![deps](https://img.shields.io/badge/dependencies-0-success)]() [![license](https://img.shields.io/badge/license-MIT-blue)]()
6
+ [![npm](https://img.shields.io/npm/v/jpath-cli)](https://npmjs.com/package/jpath-cli)
7
+
8
+ ---
9
+
10
+ ## Why?
11
+
12
+ `jq` is brilliant but its filter language is its own dialect with a steep learning curve. For the vast majority of real work — *"give me the `name` field of every user over 30"* — you shouldn't need to reach for a manual. `jpath` uses an intuitive dot/bracket path you already know from JavaScript, adds a tiny filter syntax, and stays **zero-dependency** so it installs instantly and audits clean.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ npm install -g jpath-cli
18
+ ```
19
+
20
+ Or run it directly with `npx jpath ...` — no install needed.
21
+
22
+ ## Quickstart
23
+
24
+ ```bash
25
+ # Extract a single value
26
+ echo '{"user":{"name":"Ada","age":36}}' | jpath .user.name
27
+ # "Ada"
28
+
29
+ # All names from an array
30
+ cat users.json | jpath 'users[*].name'
31
+
32
+ # Filter + extract
33
+ cat users.json | jpath 'users[?(@.age >= 18)].name'
34
+
35
+ # Every "id" at any depth
36
+ cat data.json | jpath '..id'
37
+
38
+ # Pretty table
39
+ cat items.json | jpath '[*]' -o table -k id,name,price
40
+ ```
41
+
42
+ ## Path syntax
43
+
44
+ | Syntax | Meaning | Example |
45
+ |---|---|---|
46
+ | `.foo` | object key `foo` | `.user.name` |
47
+ | `['foo.bar']` | bracket key (supports dots/spaces) | `['weird.key']` |
48
+ | `[0]` | array index | `arr[0]` |
49
+ | `[-1]` | last element | `arr[-1]` |
50
+ | `[*]` | wildcard — all elements / all values | `users[*]` |
51
+ | `[1:4]` | slice (start inclusive, end exclusive) | `arr[1:4]` |
52
+ | `..key` | recursive descent — `key` at any depth | `..id` |
53
+ | `[?(expr)]` | filter — keep matching elements | `[?(@.price > 10)]` |
54
+
55
+ A leading `$` (root) is accepted and ignored: `$.user.name` ≡ `.user.name`.
56
+
57
+ ## Filter expressions
58
+
59
+ Inside `[?(...)]`, use `@` to refer to the current element:
60
+
61
+ | Expression | Matches when… |
62
+ |---|---|
63
+ | `@.active` | `active` is truthy |
64
+ | `!@.deleted` | `deleted` is falsy |
65
+ | `@.age > 30` | numeric comparison (`>=`, `<`, `<=`, `==`, `!=`) |
66
+ | `@.name == 'Ada'` | equality (loose: `"10" == 10`) |
67
+ | `@.tags contains 'go'` | array/string contains |
68
+ | `@.name startsWith 'A'` | string prefix |
69
+ | `@.name =~ /^A/i` | regex match (`/pattern/flags`) |
70
+ | `@.id in [1,2,3]` | membership |
71
+
72
+ Combine with the element path to pull a field: `users[?(@.age > 30)].name`.
73
+
74
+ ## Output formats (`-o, --output`)
75
+
76
+ | Format | Description |
77
+ |---|---|
78
+ | `json` | Pretty JSON (default) |
79
+ | `compact` | Minified JSON |
80
+ | `jsonl` | Newline-delimited (one JSON per array item) |
81
+ | `raw` | Raw scalar (no quotes) |
82
+ | `table` | Aligned columns (`-k id,name` to choose) |
83
+ | `keys` | List object keys / array indices |
84
+ | `count` | Length of the result |
85
+
86
+ ## Options
87
+
88
+ ```
89
+ -o, --output <fmt> output format
90
+ -k, --keys <a,b> columns for table output
91
+ -i, --indent <n> JSON indent (default 2)
92
+ -r, --raw shortcut for -o raw
93
+ --null-as <str> emit this string when the result is null/undefined
94
+ -v, --version print version
95
+ -h, --help show help
96
+ --demo run a built-in demo
97
+ ```
98
+
99
+ ## Recipes
100
+
101
+ ```bash
102
+ # Names of active users, one per line
103
+ jpath 'users[?(@.active)].name' data.json -o jsonl
104
+
105
+ # In-stock products, tabular
106
+ jpath 'products[?(@.stock > 0)]' data.json -o table -k sku,name,price
107
+
108
+ # Count comments on a post
109
+ jpath 'post.comments[*]' data.json -o count
110
+
111
+ # Pull every URL out of a deeply-nested config
112
+ jpath '..url' config.json -o jsonl
113
+
114
+ # Last 3 log entries
115
+ jpath 'logs[-3:]' app.json
116
+
117
+ # Raw scalar for scripting
118
+ PRICE=$(jpath '.price' quote.json -r)
119
+ ```
120
+
121
+ ## As a library
122
+
123
+ ```js
124
+ const { evaluate } = require('jpath');
125
+
126
+ const data = { users: [{ age: 30 }, { age: 40 }] };
127
+ const over30 = evaluate('users[?(@.age > 30)]', data);
128
+ // [{ age: 40 }]
129
+ ```
130
+
131
+ ## Design notes
132
+
133
+ - **Zero dependencies.** Pure Node.js, no install-time risk, fast cold start.
134
+ - **Safe by construction.** Filter expressions are parsed by a small hand-written evaluator — **no `eval()`, ever.** Untrusted JSON is safe to query.
135
+ - **Predictable missing values.** A missing key or out-of-range index yields `undefined` and a silent exit — ideal for pipelines.
136
+
137
+ ## License
138
+
139
+ MIT © venture
package/bin/jpath.js ADDED
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env node
2
+ // bin/jpath.js — CLI entry point
3
+ 'use strict';
4
+
5
+ const { readFileSync } = require('fs');
6
+ const { evaluate } = require('../lib/path');
7
+ const { formatOutput } = require('../lib/format');
8
+
9
+ const VERSION = '0.1.0';
10
+
11
+ function usage() {
12
+ return `jpath v${VERSION} — query & extract data from JSON
13
+
14
+ USAGE
15
+ jpath [options] <path> [file]
16
+ cat file.json | jpath [options] <path>
17
+
18
+ PATH SYNTAX
19
+ .foo object key
20
+ ['weird.key'] bracket key (supports dots/spaces)
21
+ [0] array index ([ -1] = last)
22
+ [*] wildcard (all elements / all values)
23
+ [1:4] slice (start:end)
24
+ ..key recursive descent (key at any depth)
25
+ [?(@.price > 10)] filter
26
+ [?(@.active)] truthy filter
27
+ [?(@.name =~ /^A/)] regex filter
28
+
29
+ FILTER OPERATORS
30
+ == != > >= < <=
31
+ contains !contains
32
+ startsWith endsWith
33
+ in !in
34
+ =~ (regex; use /pattern/i form)
35
+
36
+ OUTPUT FORMATS (-o, --output)
37
+ json pretty JSON (default)
38
+ compact minified JSON
39
+ jsonl newline-delimited JSON (one per array item)
40
+ raw raw scalar value (no quotes)
41
+ table aligned columns
42
+ keys list object keys / array indices
43
+ count length of result
44
+
45
+ OPTIONS
46
+ -o, --output <fmt> output format (see above)
47
+ -k, --keys <a,b> columns for table output
48
+ -i, --indent <n> JSON indent (default 2)
49
+ -r, --raw shortcut for -o raw
50
+ --null-as string to emit when result is null/undefined (default: emits nothing)
51
+ -v, --version print version
52
+ -h, --help show this help
53
+ --demo run a built-in demo
54
+
55
+ EXAMPLES
56
+ echo '{"a":{"b":[1,2,3]}}' | jpath .a.b[1] # -> 2
57
+ echo '[{"n":1},{"n":2}]' | jpath '[*].n' # -> [1,2]
58
+ cat users.json | jpath '[?(@.age >= 18)].name' # names of adults
59
+ cat data.json | jpath '..id' # every 'id' at any depth
60
+ cat items.json | jpath -o table '[*]' -k id,name,price
61
+ `;
62
+ }
63
+
64
+ function parseArgs(argv) {
65
+ const opts = { path: null, file: null, output: 'json', keys: [], indent: 2, raw: false, nullAs: null, demo: false };
66
+ const args = argv.slice(2);
67
+ for (let i = 0; i < args.length; i++) {
68
+ const a = args[i];
69
+ if (a === '-h' || a === '--help') { opts.help = true; }
70
+ else if (a === '-v' || a === '--version') { opts.version = true; }
71
+ else if (a === '-o' || a === '--output') { opts.output = args[++i]; }
72
+ else if (a === '-k' || a === '--keys') { opts.keys = args[++i].split(',').map((s) => s.trim()); }
73
+ else if (a === '-i' || a === '--indent') { opts.indent = parseInt(args[++i], 10); }
74
+ else if (a === '-r' || a === '--raw') { opts.raw = true; opts.output = 'raw'; }
75
+ else if (a === '--null-as') { opts.nullAs = args[++i]; }
76
+ else if (a === '--demo') { opts.demo = true; }
77
+ else if (a.startsWith('-')) { throw new Error(`unknown option: ${a}`); }
78
+ else if (opts.path == null) { opts.path = a; }
79
+ else if (opts.file == null) { opts.file = a; }
80
+ else { throw new Error(`unexpected argument: ${a}`); }
81
+ }
82
+ return opts;
83
+ }
84
+
85
+ function readInput(file) {
86
+ if (file) return readFileSync(file, 'utf8');
87
+ // read from stdin if it's not a TTY; otherwise nothing
88
+ try {
89
+ return readFileSync(0, 'utf8');
90
+ } catch {
91
+ return '';
92
+ }
93
+ }
94
+
95
+ function runDemo() {
96
+ const demo = {
97
+ users: [
98
+ { id: 1, name: 'Ada', age: 36, role: 'engineer', active: true, tags: ['rust', 'systems'] },
99
+ { id: 2, name: 'Grace', age: 28, role: 'engineer', active: true, tags: ['go', 'cloud'] },
100
+ { id: 3, name: 'Linus', age: 54, role: 'maintainer', active: false, tags: ['c', 'kernel'] },
101
+ { id: 4, name: 'Margaret', age: 41, role: 'engineer', active: true, tags: ['python', 'data'] },
102
+ ],
103
+ meta: { total: 4, env: 'prod', nested: { deep: { id: 'hidden' } } },
104
+ };
105
+ const json = JSON.stringify(demo);
106
+ const examples = [
107
+ ['.users[0].name', 'first user name'],
108
+ ['.users[-1].name', 'last user name'],
109
+ ['.users[*].name', 'all names'],
110
+ ['.users[?(@.age > 30)].name', 'names of users over 30'],
111
+ ['.users[?(@.tags contains "go")].name', 'users tagged "go"'],
112
+ ['.users[?(@.active)].name', 'active users'],
113
+ ['.users[1:3].name', 'slice users 1..3'],
114
+ ['..id', 'every id at any depth'],
115
+ ['.users[*].name', null, 'count', 'count of names'],
116
+ ];
117
+ console.log(`jpath v${VERSION} — demo\nInput: ${json}\n`);
118
+ for (const [path, label, fmtOverride, label2] of examples) {
119
+ const val = evaluate(path, demo);
120
+ const fmt = fmtOverride || 'json';
121
+ const out = formatOutput(val, fmt);
122
+ const title = label2 || label;
123
+ console.log(`# ${title}`);
124
+ console.log(` path: ${path}`);
125
+ console.log(` ${out}\n`);
126
+ }
127
+ }
128
+
129
+ function main() {
130
+ let opts;
131
+ try { opts = parseArgs(process.argv); }
132
+ catch (e) { process.stderr.write(`error: ${e.message}\n\n${usage()}`); process.exit(2); }
133
+
134
+ if (opts.help) { process.stdout.write(usage()); return; }
135
+ if (opts.version) { process.stdout.write(`jpath v${VERSION}\n`); return; }
136
+ if (opts.demo) { runDemo(); return; }
137
+
138
+ if (opts.path == null) {
139
+ process.stderr.write(`error: a path argument is required\n\n${usage()}`);
140
+ process.exit(2);
141
+ }
142
+
143
+ let input;
144
+ try { input = readInput(opts.file); }
145
+ catch (e) { process.stderr.write(`error reading input: ${e.message}\n`); process.exit(1); }
146
+
147
+ if (!input || !input.trim()) {
148
+ process.stderr.write(`error: no JSON input (provide a file or pipe JSON via stdin)\n`);
149
+ process.exit(1);
150
+ }
151
+
152
+ let root;
153
+ try { root = JSON.parse(input); }
154
+ catch (e) { process.stderr.write(`error: invalid JSON input: ${e.message}\n`); process.exit(1); }
155
+
156
+ let result;
157
+ try { result = evaluate(opts.path, root); }
158
+ catch (e) {
159
+ process.stderr.write(`error: ${e.message}\n`);
160
+ process.exit(1);
161
+ }
162
+
163
+ if (result === undefined || result === null) {
164
+ if (opts.nullAs != null) process.stdout.write(opts.nullAs + '\n');
165
+ else process.stderr.write(''); // no output for null/missing
166
+ return;
167
+ }
168
+
169
+ let out;
170
+ try {
171
+ out = formatOutput(result, opts.output, { indent: opts.indent, keys: opts.keys });
172
+ } catch (e) {
173
+ process.stderr.write(`error: ${e.message}\n`); process.exit(1);
174
+ }
175
+ process.stdout.write(out + (out.endsWith('\n') ? '' : '\n'));
176
+ }
177
+
178
+ main();
package/lib/format.js ADDED
@@ -0,0 +1,90 @@
1
+ // lib/format.js — output formatters (json, json-compact, jsonl, raw, table, keys, count)
2
+ 'use strict';
3
+
4
+ function formatOutput(value, format, opts = {}) {
5
+ switch ((format || 'json').toLowerCase()) {
6
+ case 'json':
7
+ case 'json5':
8
+ case 'pretty':
9
+ return JSON.stringify(value, null, opts.indent == null ? 2 : opts.indent);
10
+
11
+ case 'jsonc':
12
+ case 'compact':
13
+ case 'min':
14
+ return JSON.stringify(value);
15
+
16
+ case 'jsonl':
17
+ case 'ndjson': {
18
+ const arr = Array.isArray(value) ? value : [value];
19
+ return arr.map((v) => JSON.stringify(v)).join('\n');
20
+ }
21
+
22
+ case 'raw':
23
+ if (typeof value === 'string') return value;
24
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
25
+ if (value == null) return 'null';
26
+ return JSON.stringify(value);
27
+
28
+ case 'table':
29
+ case 'columns': {
30
+ const arr = Array.isArray(value) ? value : [value];
31
+ return renderTable(arr, opts.keys);
32
+ }
33
+
34
+ case 'keys': {
35
+ if (Array.isArray(value)) return value.map((v, i) => String(i)).join('\n');
36
+ if (value && typeof value === 'object') return Object.keys(value).join('\n');
37
+ return '';
38
+ }
39
+
40
+ case 'count':
41
+ case 'len':
42
+ case 'length': {
43
+ if (Array.isArray(value)) return String(value.length);
44
+ if (value && typeof value === 'object') return String(Object.keys(value).length);
45
+ if (typeof value === 'string') return String(value.length);
46
+ return value == null ? '0' : '1';
47
+ }
48
+
49
+ default:
50
+ throw new Error(`Unknown format: ${format}`);
51
+ }
52
+ }
53
+
54
+ function pickTableKeys(arr) {
55
+ const keySet = new Set();
56
+ for (const item of arr) {
57
+ if (item && typeof item === 'object' && !Array.isArray(item)) {
58
+ for (const k of Object.keys(item)) keySet.add(k);
59
+ }
60
+ }
61
+ return [...keySet];
62
+ }
63
+
64
+ function renderTable(arr, explicitKeys) {
65
+ const rows = arr.filter((r) => r != null);
66
+ if (rows.length === 0) return '(empty)';
67
+ const scalar = rows.every((r) => typeof r !== 'object' || r === null);
68
+ if (scalar) {
69
+ return rows.map((r) => String(r)).join('\n');
70
+ }
71
+ const keys = explicitKeys && explicitKeys.length ? explicitKeys : pickTableKeys(rows);
72
+ const cell = (v) => {
73
+ if (v == null) return '';
74
+ if (typeof v === 'object') return JSON.stringify(v);
75
+ return String(v);
76
+ };
77
+ const widths = keys.map((k) => Math.max(k.length, ...rows.map((r) => cell(r[k]).length)));
78
+ const header = keys.map((k, i) => pad(k, widths[i])).join(' ');
79
+ const sep = widths.map((w) => '-'.repeat(w)).join(' ');
80
+ const body = rows.map((r) => keys.map((k, i) => pad(cell(r[k]), widths[i])).join(' ')).join('\n');
81
+ return [header, sep, body].join('\n');
82
+ }
83
+
84
+ function pad(s, n) {
85
+ s = String(s);
86
+ if (s.length >= n) return s;
87
+ return s + ' '.repeat(n - s.length);
88
+ }
89
+
90
+ module.exports = { formatOutput, renderTable, pickTableKeys };
package/lib/path.js ADDED
@@ -0,0 +1,428 @@
1
+ // lib/path.js — the path tokenizer + evaluator
2
+ // Supports: dot access, bracket index, wildcard *, key filter [?(...)], slice [start:end]
3
+ // Zero dependencies. Pure functions, fully testable.
4
+
5
+ 'use strict';
6
+
7
+ /**
8
+ * Tokenize a path string into a list of segment descriptors.
9
+ * Grammar (informal):
10
+ * '' -> root (whole document)
11
+ * .foo -> object key 'foo'
12
+ * ['foo'] -> object key 'foo' (bracket form, supports keys with dots/special chars)
13
+ * [0] -> array index 0
14
+ * [-1] -> array index from end (-1 = last)
15
+ * [*] -> wildcard: every element of an array / every value of an object
16
+ * [1:3] -> array slice from index 1 (inclusive) to 3 (exclusive)
17
+ * [?(@.price > 10)] -> filter: keep array elements where the expression is truthy
18
+ * [?(@.active)] -> filter: keep elements where the path is truthy
19
+ * ..key -> recursive descent: match 'key' at any depth
20
+ *
21
+ * Tokenizing is deliberately permissive but throws a clear ParseError on garbage.
22
+ */
23
+ class ParseError extends Error {
24
+ constructor(message, position, source) {
25
+ super(`jpath parse error at position ${position}: ${message}${source ? ` (near: ...${source.slice(Math.max(0, position - 10), position + 10)}...)` : ''}`);
26
+ this.name = 'ParseError';
27
+ this.position = position;
28
+ }
29
+ }
30
+
31
+ // A segment is one of:
32
+ // { t: 'key', k: 'foo' }
33
+ // { t: 'index', i: 0 }
34
+ // { t: 'wild' }
35
+ // { t: 'slice', start: 0, end: undefined, step: undefined }
36
+ // { t: 'filter', expr: 'tokenized-filter' }
37
+ // { t: 'desc', k: 'foo' } // recursive descent for key 'foo'
38
+
39
+ function tokenize(path) {
40
+ if (path == null) return [];
41
+ const s = String(path).trim();
42
+ const segs = [];
43
+ let i = 0;
44
+
45
+ // accept a leading '$' (root reference) or '@' as a no-op start
46
+ if (s[i] === '$') { i++; if (s[i] === '.' && s[i + 1] !== '.') i++; }
47
+ else if (s[i] === '@') { i++; }
48
+
49
+ while (i < s.length) {
50
+ if (s[i] === '.') {
51
+ // dot — could be recursive descent (..) or a single-child key
52
+ if (s[i + 1] === '.') {
53
+ // recursive descent: ..key
54
+ i += 2;
55
+ const key = readKey(s, i);
56
+ if (key.value === '' && key.consumed === 0) throw new ParseError('expected key after ".."', i, s);
57
+ i += key.consumed;
58
+ segs.push({ t: 'desc', k: key.value });
59
+ } else {
60
+ i++; // consume the dot
61
+ // Handle .* as wildcard (dot-wildcard syntax, e.g. $.store.*)
62
+ if (s[i] === '*') { segs.push({ t: 'wild' }); i++; continue; }
63
+ const key = readKey(s, i);
64
+ if (key.value === '' && key.consumed === 0) {
65
+ // trailing dot or dot before bracket — treat as no-op
66
+ continue;
67
+ }
68
+ i += key.consumed;
69
+ segs.push({ t: 'key', k: key.value });
70
+ }
71
+ } else if (s[i] === '[') {
72
+ const { seg, next } = readBracket(s, i);
73
+ segs.push(seg);
74
+ i = next;
75
+ } else {
76
+ // bare key not preceded by a dot (e.g. "foo" at start, or "foo.bar")
77
+ const key = readKey(s, i);
78
+ if (key.value === '' && key.consumed === 0) {
79
+ throw new ParseError(`unexpected character '${s[i]}'`, i, s);
80
+ }
81
+ i += key.consumed;
82
+ segs.push({ t: 'key', k: key.value });
83
+ }
84
+ }
85
+ return segs;
86
+ }
87
+
88
+ function readKey(s, i) {
89
+ // a key is a run of chars until a '.', '[', or end. Also supports single/double-quoted keys.
90
+ if (s[i] === '"' || s[i] === "'") {
91
+ const quote = s[i];
92
+ let j = i + 1;
93
+ let out = '';
94
+ while (j < s.length && s[j] !== quote) {
95
+ if (s[j] === '\\' && j + 1 < s.length) { out += s[j + 1]; j += 2; }
96
+ else { out += s[j]; j++; }
97
+ }
98
+ if (s[j] !== quote) throw new ParseError('unterminated quoted key', i, s);
99
+ return { value: out, consumed: (j + 1) - i };
100
+ }
101
+ let j = i;
102
+ let out = '';
103
+ while (j < s.length && s[j] !== '.' && s[j] !== '[') {
104
+ out += s[j];
105
+ j++;
106
+ }
107
+ return { value: out, consumed: j - i };
108
+ }
109
+
110
+ function readBracket(s, i) {
111
+ // s[i] === '['. Find matching ']' (naive — filters can contain brackets inside, so we track depth).
112
+ let depth = 1;
113
+ let j = i + 1;
114
+ while (j < s.length && depth > 0) {
115
+ if (s[j] === '[') depth++;
116
+ else if (s[j] === ']') depth--;
117
+ if (depth === 0) break;
118
+ j++;
119
+ }
120
+ if (depth !== 0) throw new ParseError('unterminated bracket', i, s);
121
+ const inner = s.slice(i + 1, j).trim();
122
+ const next = j + 1;
123
+ const seg = parseBracketInner(inner, i);
124
+ return { seg, next };
125
+ }
126
+
127
+ function parseBracketInner(inner, pos) {
128
+ if (inner === '*') return { t: 'wild' };
129
+
130
+ // slice: contains a ':' and starts/ends with digits / colons
131
+ if (/^-?\d*:-?\d*(:-?\d+)?$/.test(inner) && inner.includes(':')) {
132
+ const parts = inner.split(':');
133
+ const start = parts[0] === '' ? undefined : parseInt(parts[0], 10);
134
+ const end = parts[1] === '' ? undefined : parseInt(parts[1], 10);
135
+ const step = parts[2] !== undefined ? parseInt(parts[2], 10) : undefined;
136
+ return { t: 'slice', start, end, step };
137
+ }
138
+
139
+ // quoted string key: 'foo' or "foo"
140
+ if ((inner.startsWith("'") && inner.endsWith("'")) || (inner.startsWith('"') && inner.endsWith('"'))) {
141
+ return { t: 'key', k: inner.slice(1, -1) };
142
+ }
143
+
144
+ // filter: ?(...)
145
+ if (inner.startsWith('?')) {
146
+ const expr = inner.slice(1).trim();
147
+ // strip surrounding parens if the whole thing is wrapped
148
+ const e = expr.startsWith('(') && expr.endsWith(')') ? expr.slice(1, -1).trim() : expr;
149
+ return { t: 'filter', expr: e };
150
+ }
151
+
152
+ // plain integer index
153
+ if (/^-?\d+$/.test(inner)) {
154
+ return { t: 'index', i: parseInt(inner, 10) };
155
+ }
156
+
157
+ // fallback: treat as an unquoted key
158
+ return { t: 'key', k: inner };
159
+ }
160
+
161
+ // ---- evaluation ----
162
+
163
+ function evalFilter(expr, item, root) {
164
+ // expr like: @.price > 10 | @.tags contains 'red' | @.active | @.n in [1,2,3]
165
+ // We support a small, safe expression language. No eval(), ever.
166
+ const m = matchExpr(expr);
167
+ if (!m) throw new ParseError(`unsupported filter expression: ${expr}`, 0, expr);
168
+ const { left, op, right } = m;
169
+ const leftVal = resolveFilterRef(left, item, root);
170
+ const rightVal = resolveRight(right, item, root);
171
+
172
+ switch (op) {
173
+ case 'truthy': return isTruthy(leftVal);
174
+ case '!': return !isTruthy(leftVal);
175
+ case '==': return looseEq(leftVal, rightVal);
176
+ case '!=': return !looseEq(leftVal, rightVal);
177
+ case '>': return cmpNum(leftVal, rightVal) > 0;
178
+ case '>=': return cmpNum(leftVal, rightVal) >= 0;
179
+ case '<': return cmpNum(leftVal, rightVal) < 0;
180
+ case '<=': return cmpNum(leftVal, rightVal) <= 0;
181
+ case 'contains': return containsCheck(leftVal, rightVal);
182
+ case '!contains': return !containsCheck(leftVal, rightVal);
183
+ case 'in': return Array.isArray(rightVal) && rightVal.some((r) => looseEq(leftVal, r));
184
+ case '!in': return !(Array.isArray(rightVal) && rightVal.some((r) => looseEq(leftVal, r)));
185
+ case 'startsWith': return typeof leftVal === 'string' && String(rightVal).length > 0 && leftVal.startsWith(String(rightVal));
186
+ case 'endsWith': return typeof leftVal === 'string' && String(rightVal).length > 0 && leftVal.endsWith(String(rightVal));
187
+ case '=~': return typeof leftVal === 'string' && safeRegex(rightVal).test(leftVal);
188
+ default: throw new ParseError(`unknown operator ${op}`, 0, expr);
189
+ }
190
+ }
191
+
192
+ // match an expression into { left, op, right }
193
+ function matchExpr(expr) {
194
+ // order matters — longest ops first
195
+ const ops = [
196
+ ['!contains', '!contains'],
197
+ ['contains', 'contains'],
198
+ ['startsWith', 'startsWith'],
199
+ ['endsWith', 'endsWith'],
200
+ ['!in', '!in'],
201
+ ['in', 'in'],
202
+ ['>=', '>='],
203
+ ['<=', '<='],
204
+ ['=~', '=~'],
205
+ ['==', '=='],
206
+ ['!=', '!='],
207
+ ['>', '>'],
208
+ ['<', '<'],
209
+ ];
210
+ for (const [sym, name] of ops) {
211
+ const idx = findOperator(expr, sym);
212
+ if (idx !== -1) {
213
+ return { left: expr.slice(0, idx).trim(), op: name, right: expr.slice(idx + sym.length).trim() };
214
+ }
215
+ }
216
+ // negation prefix
217
+ if (expr.startsWith('!')) return { left: expr.slice(1).trim(), op: '!', right: undefined };
218
+ return { left: expr.trim(), op: 'truthy', right: undefined };
219
+ }
220
+
221
+ // find an operator that is NOT inside a quoted string or bracket
222
+ function findOperator(expr, sym) {
223
+ let depth = 0;
224
+ let inStr = false;
225
+ let strCh = '';
226
+ for (let i = 0; i <= expr.length - sym.length; i++) {
227
+ const c = expr[i];
228
+ if (inStr) {
229
+ if (c === strCh && expr[i - 1] !== '\\') inStr = false;
230
+ continue;
231
+ }
232
+ if (c === '"' || c === "'") { inStr = true; strCh = c; continue; }
233
+ if (c === '[' || c === '(') depth++;
234
+ else if (c === ']' || c === ')') depth--;
235
+ if (depth === 0 && expr.slice(i, i + sym.length) === sym) {
236
+ // avoid matching '=' inside '==' or '!=' for single-char '=' (not used here) and similar
237
+ return i;
238
+ }
239
+ }
240
+ return -1;
241
+ }
242
+
243
+ function resolveFilterRef(ref, item, root) {
244
+ // ref like '@.foo.bar' or '$.config.env' or a literal
245
+ if (ref === '@' || ref === '') return item;
246
+ if (ref.startsWith('@')) {
247
+ const p = ref.slice(1);
248
+ const segs = tokenize(p);
249
+ let cur = item;
250
+ for (const s of segs) {
251
+ const r = applySeg(s, cur, root);
252
+ cur = r;
253
+ if (cur === undefined) return undefined;
254
+ }
255
+ return cur;
256
+ }
257
+ if (ref.startsWith('$')) {
258
+ const p = ref.slice(1);
259
+ const segs = tokenize(p);
260
+ let cur = root;
261
+ for (const s of segs) {
262
+ cur = applySeg(s, cur, root);
263
+ if (cur === undefined) return undefined;
264
+ }
265
+ return cur;
266
+ }
267
+ return resolveLiteral(ref);
268
+ }
269
+
270
+ function resolveRight(ref, item, root) {
271
+ if (ref === undefined) return undefined;
272
+ if (ref.startsWith('[') && ref.endsWith(']')) {
273
+ // array literal
274
+ const inner = ref.slice(1, -1).trim();
275
+ if (inner === '') return [];
276
+ return inner.split(',').map((p) => {
277
+ const r = resolveRight(p.trim(), item, root);
278
+ return r;
279
+ });
280
+ }
281
+ if (ref.startsWith('@') || ref.startsWith('$')) return resolveFilterRef(ref, item, root);
282
+ return resolveLiteral(ref);
283
+ }
284
+
285
+ function resolveLiteral(s) {
286
+ if ((s.startsWith("'") && s.endsWith("'")) || (s.startsWith('"') && s.endsWith('"'))) {
287
+ return s.slice(1, -1).replace(/\\(.)/g, '$1');
288
+ }
289
+ if (s === 'true') return true;
290
+ if (s === 'false') return false;
291
+ if (s === 'null') return null;
292
+ if (/^-?\d+(\.\d+)?$/.test(s)) return Number(s);
293
+ // bareword treated as string for contains/startsWith style ops
294
+ return s;
295
+ }
296
+
297
+ function isTruthy(v) {
298
+ if (v == null) return false;
299
+ if (Array.isArray(v)) return v.length > 0;
300
+ if (typeof v === 'object') return Object.keys(v).length > 0;
301
+ if (typeof v === 'string') return v.length > 0;
302
+ if (typeof v === 'number') return v !== 0 && !Number.isNaN(v);
303
+ return Boolean(v);
304
+ }
305
+
306
+ function looseEq(a, b) {
307
+ if (a === b) return true;
308
+ if (a == null && b == null) return true;
309
+ if (typeof a === 'number' && typeof b === 'number') return a === b;
310
+ if (typeof a === 'string' && typeof b === 'string') return a === b;
311
+ if (typeof a === 'boolean' && typeof b === 'boolean') return a === b;
312
+ // loose: "10" == 10
313
+ return String(a) === String(b);
314
+ }
315
+
316
+ function cmpNum(a, b) {
317
+ if (typeof a !== 'number' || typeof b !== 'number') {
318
+ a = Number(a); b = Number(b);
319
+ }
320
+ if (Number.isNaN(a) || Number.isNaN(b)) return 0;
321
+ return a > b ? 1 : a < b ? -1 : 0;
322
+ }
323
+
324
+ function containsCheck(haystack, needle) {
325
+ if (typeof haystack === 'string') return haystack.includes(String(needle));
326
+ if (Array.isArray(haystack)) return haystack.some((h) => looseEq(h, needle));
327
+ if (haystack && typeof haystack === 'object') return Object.keys(haystack).some((k) => looseEq(k, needle));
328
+ return false;
329
+ }
330
+
331
+ function safeRegex(pattern) {
332
+ // pattern may be /foo/i or a bare string
333
+ let src = pattern;
334
+ let flags = '';
335
+ if (typeof pattern === 'string' && pattern.startsWith('/') && pattern.lastIndexOf('/') > 0) {
336
+ const lastSlash = pattern.lastIndexOf('/');
337
+ src = pattern.slice(1, lastSlash);
338
+ flags = pattern.slice(lastSlash + 1);
339
+ }
340
+ try { return new RegExp(src, flags); } catch { return { test: () => false }; }
341
+ }
342
+
343
+ // apply a single segment to a value. Returns the resolved value (may be array for wild/slice/desc).
344
+ function applySeg(seg, val, root) {
345
+ switch (seg.t) {
346
+ case 'key':
347
+ if (val == null) return undefined;
348
+ if (Array.isArray(val)) return val.map((v) => (v && typeof v === 'object') ? v[seg.k] : undefined);
349
+ if (typeof val === 'object') return val[seg.k];
350
+ return undefined;
351
+
352
+ case 'index': {
353
+ if (!Array.isArray(val)) return undefined;
354
+ let idx = seg.i;
355
+ if (idx < 0) idx += val.length;
356
+ if (idx < 0 || idx >= val.length) return undefined;
357
+ return val[idx];
358
+ }
359
+
360
+ case 'wild': {
361
+ if (Array.isArray(val)) return val.slice(); // copy
362
+ if (val && typeof val === 'object') return Object.values(val);
363
+ return [];
364
+ }
365
+
366
+ case 'slice': {
367
+ if (!Array.isArray(val)) return [];
368
+ const len = val.length;
369
+ let start = seg.start == null ? 0 : (seg.start < 0 ? seg.start + len : seg.start);
370
+ let end = seg.end == null ? len : (seg.end < 0 ? seg.end + len : seg.end);
371
+ start = Math.max(0, Math.min(len, start));
372
+ end = Math.max(0, Math.min(len, end));
373
+ const step = seg.step || 1;
374
+ const out = [];
375
+ if (step > 0) for (let i = start; i < end; i += step) out.push(val[i]);
376
+ else for (let i = start; i > end; i += step) out.push(val[i]);
377
+ return out;
378
+ }
379
+
380
+ case 'filter': {
381
+ if (!Array.isArray(val)) {
382
+ // allow filtering a single object by wrapping
383
+ val = [val];
384
+ }
385
+ return val.filter((item) => {
386
+ try { return isTruthy(evalFilter(seg.expr, item, root)); }
387
+ catch { return false; }
388
+ });
389
+ }
390
+
391
+ case 'desc': {
392
+ // recursive descent: collect all values at any depth whose key === seg.k (for objects)
393
+ // or, when applied to an array, recurse into each element.
394
+ const results = [];
395
+ const walk = (node) => {
396
+ if (Array.isArray(node)) {
397
+ for (const el of node) walk(el);
398
+ } else if (node && typeof node === 'object') {
399
+ for (const [k, v] of Object.entries(node)) {
400
+ if (k === seg.k) results.push(v);
401
+ walk(v);
402
+ }
403
+ }
404
+ };
405
+ walk(val);
406
+ return results;
407
+ }
408
+
409
+ default:
410
+ return undefined;
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Evaluate a full path against a root document. Returns the matched value(s).
416
+ * Wildcards/filters naturally produce arrays; a single key/index returns the scalar.
417
+ */
418
+ function evaluate(path, root) {
419
+ const segs = tokenize(path);
420
+ let cur = root;
421
+ for (const seg of segs) {
422
+ cur = applySeg(seg, cur, root);
423
+ if (cur === undefined) break;
424
+ }
425
+ return cur;
426
+ }
427
+
428
+ module.exports = { tokenize, evaluate, applySeg, evalFilter, ParseError };
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "jpath-cli",
3
+ "version": "0.1.0",
4
+ "description": "Query and extract data from JSON with a simple, powerful path syntax. A focused zero-dependency CLI — jq's little sibling for the 90% case.",
5
+ "license": "MIT",
6
+ "author": "takeaseatventure",
7
+ "keywords": [
8
+ "json",
9
+ "query",
10
+ "jq",
11
+ "path",
12
+ "extract",
13
+ "cli",
14
+ "filter",
15
+ "transform",
16
+ "jpath"
17
+ ],
18
+ "homepage": "https://github.com/takeaseatventure/jpath-cli#readme",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/takeaseatventure/jpath-cli.git"
22
+ },
23
+ "bin": {
24
+ "jpath": "./bin/jpath.js"
25
+ },
26
+ "files": [
27
+ "bin/",
28
+ "lib/",
29
+ "test/",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "engines": {
34
+ "node": ">=18"
35
+ },
36
+ "scripts": {
37
+ "test": "node --test test/*.test.js",
38
+ "demo": "node bin/jpath.js --demo"
39
+ },
40
+ "bugs": {
41
+ "url": "https://github.com/takeaseatventure/jpath-cli/issues"
42
+ }
43
+ }
@@ -0,0 +1,169 @@
1
+ 'use strict';
2
+ const { test } = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const { tokenize, evaluate, evalFilter, ParseError } = require('../lib/path');
5
+ const { formatOutput, renderTable } = require('../lib/format');
6
+
7
+ const DOC = {
8
+ a: { b: { c: 42 } },
9
+ arr: [10, 20, 30],
10
+ users: [
11
+ { id: 1, name: 'Ada', age: 36, active: true, tags: ['rust', 'systems'] },
12
+ { id: 2, name: 'Grace', age: 28, active: true, tags: ['go', 'cloud'] },
13
+ { id: 3, name: 'Linus', age: 54, active: false, tags: ['c', 'kernel'] },
14
+ ],
15
+ 'dotted.key': 'x',
16
+ nested: { deep: { id: 'deep-id' } },
17
+ };
18
+
19
+ // ---- tokenizer ----
20
+ test('tokenize: dot path', () => {
21
+ assert.deepEqual(tokenize('.a.b.c'), [
22
+ { t: 'key', k: 'a' }, { t: 'key', k: 'b' }, { t: 'key', k: 'c' },
23
+ ]);
24
+ });
25
+ test('tokenize: leading $ is root no-op', () => {
26
+ assert.deepEqual(tokenize('$.a'), [{ t: 'key', k: 'a' }]);
27
+ });
28
+ test('tokenize: bracket index', () => {
29
+ assert.deepEqual(tokenize('arr[0]'), [{ t: 'key', k: 'arr' }, { t: 'index', i: 0 }]);
30
+ });
31
+ test('tokenize: bracket negative index', () => {
32
+ assert.deepEqual(tokenize('arr[-1]'), [{ t: 'key', k: 'arr' }, { t: 'index', i: -1 }]);
33
+ });
34
+ test('tokenize: wildcard', () => {
35
+ assert.deepEqual(tokenize('users[*]'), [{ t: 'key', k: 'users' }, { t: 'wild' }]);
36
+ });
37
+ test('tokenize: slice', () => {
38
+ assert.deepEqual(tokenize('arr[1:2]'), [{ t: 'key', k: 'arr' }, { t: 'slice', start: 1, end: 2, step: undefined }]);
39
+ });
40
+ test('tokenize: quoted key supports dots', () => {
41
+ assert.deepEqual(tokenize("['dotted.key']"), [{ t: 'key', k: 'dotted.key' }]);
42
+ });
43
+ test('tokenize: filter', () => {
44
+ const segs = tokenize('users[?(@.age > 30)]');
45
+ assert.equal(segs.length, 2);
46
+ assert.equal(segs[1].t, 'filter');
47
+ assert.equal(segs[1].expr, '@.age > 30');
48
+ });
49
+ test('tokenize: recursive descent', () => {
50
+ assert.deepEqual(tokenize('..id'), [{ t: 'desc', k: 'id' }]);
51
+ });
52
+ test('tokenize: empty path -> root', () => {
53
+ assert.deepEqual(tokenize(''), []);
54
+ });
55
+ test('tokenize: unterminated bracket throws', () => {
56
+ assert.throws(() => tokenize('arr[0'), ParseError);
57
+ });
58
+
59
+ // ---- evaluate: basic ----
60
+ test('evaluate: dot access', () => {
61
+ assert.equal(evaluate('.a.b.c', DOC), 42);
62
+ });
63
+ test('evaluate: array index', () => {
64
+ assert.equal(evaluate('arr[0]', DOC), 10);
65
+ });
66
+ test('evaluate: negative index', () => {
67
+ assert.equal(evaluate('arr[-1]', DOC), 30);
68
+ });
69
+ test('evaluate: out of range index -> undefined', () => {
70
+ assert.equal(evaluate('arr[99]', DOC), undefined);
71
+ });
72
+ test('evaluate: missing key -> undefined', () => {
73
+ assert.equal(evaluate('.nope.nada', DOC), undefined);
74
+ });
75
+
76
+ // ---- evaluate: wildcards & slices ----
77
+ test('evaluate: wildcard over array returns copy', () => {
78
+ assert.deepEqual(evaluate('arr[*]', DOC), [10, 20, 30]);
79
+ });
80
+ test('evaluate: slice returns subarray', () => {
81
+ assert.deepEqual(evaluate('arr[0:2]', DOC), [10, 20]);
82
+ });
83
+ test('evaluate: wildcard key on objects returns values', () => {
84
+ const r = evaluate('.a.b[*]', { a: { b: { x: 1, y: 2 } } });
85
+ assert.deepEqual(r.sort(), [1, 2]);
86
+ });
87
+
88
+ // ---- evaluate: filters ----
89
+ test('evaluate: filter by numeric comparison', () => {
90
+ assert.deepEqual(evaluate('users[?(@.age > 30)].name', DOC), ['Ada', 'Linus']);
91
+ });
92
+ test('evaluate: filter by truthy', () => {
93
+ assert.deepEqual(evaluate('users[?(@.active)].name', DOC), ['Ada', 'Grace']);
94
+ });
95
+ test('evaluate: filter negation', () => {
96
+ assert.deepEqual(evaluate('users[?(!@.active)].name', DOC), ['Linus']);
97
+ });
98
+ test('evaluate: filter contains (array)', () => {
99
+ assert.deepEqual(evaluate("users[?(@.tags contains 'go')].name", DOC), ['Grace']);
100
+ });
101
+ test('evaluate: filter equality', () => {
102
+ assert.deepEqual(evaluate("users[?(@.name == 'Ada')].age", DOC), [36]);
103
+ });
104
+ test('evaluate: filter regex', () => {
105
+ assert.deepEqual(evaluate("users[?(@.name =~ /^A/)].name", DOC), ['Ada']);
106
+ });
107
+ test('evaluate: filter in', () => {
108
+ assert.deepEqual(evaluate('users[?(@.age in [28,54])].name', DOC), ['Grace', 'Linus']);
109
+ });
110
+
111
+ // ---- evaluate: recursive descent ----
112
+ test('evaluate: recursive descent collects all matching keys', () => {
113
+ const r = evaluate('..id', DOC);
114
+ assert.deepEqual(r.sort(), [1, 2, 3, 'deep-id']);
115
+ });
116
+
117
+ // ---- evaluate: edge cases ----
118
+ test('evaluate: root path returns whole doc', () => {
119
+ assert.equal(evaluate('', DOC), DOC);
120
+ });
121
+ test('evaluate: quoted dotted key', () => {
122
+ assert.equal(evaluate("['dotted.key']", DOC), 'x');
123
+ });
124
+
125
+ // ---- evalFilter unit ----
126
+ test('evalFilter: loose equality string/number', () => {
127
+ assert.equal(evalFilter("@.n == '5'", { n: 5 }, {}), true);
128
+ });
129
+
130
+ // ---- formatters ----
131
+ test('format: json pretty', () => {
132
+ assert.equal(formatOutput({ a: 1 }, 'json'), '{\n "a": 1\n}');
133
+ });
134
+ test('format: compact', () => {
135
+ assert.equal(formatOutput({ a: 1 }, 'compact'), '{"a":1}');
136
+ });
137
+ test('format: jsonl', () => {
138
+ assert.equal(formatOutput([1, 2, 3], 'jsonl'), '1\n2\n3');
139
+ });
140
+ test('format: raw string unwrapped', () => {
141
+ assert.equal(formatOutput('hello', 'raw'), 'hello');
142
+ });
143
+ test('format: count', () => {
144
+ assert.equal(formatOutput([1, 2, 3], 'count'), '3');
145
+ });
146
+ test('format: keys', () => {
147
+ assert.equal(formatOutput({ a: 1, b: 2 }, 'keys'), 'a\nb');
148
+ });
149
+ test('format: table renders header + rows', () => {
150
+ const out = formatOutput([{ id: 1, name: 'Ada' }, { id: 2, name: 'Bo' }], 'table');
151
+ const lines = out.split('\n');
152
+ assert.ok(lines[0].includes('id'));
153
+ assert.ok(lines[0].includes('name'));
154
+ assert.ok(lines[2].includes('Ada'));
155
+ assert.ok(lines[3].includes('Bo'));
156
+ });
157
+ test('format: unknown format throws', () => {
158
+ assert.throws(() => formatOutput({}, 'xml'));
159
+ });
160
+
161
+ // ---- renderTable edge cases ----
162
+ test('renderTable: empty array', () => {
163
+ assert.equal(renderTable([]), '(empty)');
164
+ });
165
+ test('renderTable: scalar array', () => {
166
+ assert.equal(renderTable([1, 2, 3]), '1\n2\n3');
167
+ });
168
+
169
+ console.log('All jpath unit tests registered.');