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 +21 -0
- package/README.md +139 -0
- package/bin/jpath.js +178 -0
- package/lib/format.js +90 -0
- package/lib/path.js +428 -0
- package/package.json +43 -0
- package/test/path.test.js +169 -0
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
|
+
[]() []() []()
|
|
6
|
+
[](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.');
|