@human-synthesis/norns 0.0.16 → 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/bin/norns.js +146 -6
- package/package.json +9 -3
- package/src/auto-import.js +7 -3
- package/src/config.js +17 -3
- package/src/kernel/absorb.js +279 -0
- package/src/kernel/address.js +224 -0
- package/src/kernel/adopt.js +157 -0
- package/src/kernel/emit-machines.js +87 -0
- package/src/kernel/emit-schema.js +199 -0
- package/src/kernel/emit-units.js +587 -0
- package/src/kernel/emit-wrangler.js +114 -0
- package/src/kernel/expr-compile.js +188 -0
- package/src/kernel/expr.js +290 -0
- package/src/kernel/generate.js +397 -0
- package/src/kernel/graph.js +222 -0
- package/src/kernel/index.js +71 -0
- package/src/kernel/meta.js +237 -0
- package/src/kernel/migrate.js +134 -0
- package/src/kernel/refine.js +199 -0
- package/src/kernel/trace.js +277 -0
- package/src/kernel/validate.js +92 -0
- package/src/live-client.js +72 -0
- package/src/server/boot.js +61 -4
- package/src/server/cron.js +105 -0
- package/src/server/db.js +61 -0
- package/src/server/events.js +86 -0
- package/src/server/guard.js +48 -0
- package/src/server/handle/auth.js +54 -0
- package/src/server/index.js +12 -1
- package/src/server/live.js +134 -0
- package/src/server/machine.js +35 -0
- package/src/server/page.js +7 -3
- package/src/server/room.js +162 -0
- package/src/server/storage.js +97 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expression compilers (K-04) — one AST, three consumers:
|
|
3
|
+
*
|
|
4
|
+
* evalExpr reference evaluator (app.trace, tests, runtime interp)
|
|
5
|
+
* compileGuard AST → Civet/JS guard expression over `row` and `user`
|
|
6
|
+
* compileWhere AST → Drizzle where fragment via an operator namespace
|
|
7
|
+
*
|
|
8
|
+
* The guard compiler and the evaluator agree exactly — the fuzz suite
|
|
9
|
+
* checks compiled output against evalExpr on random rows. compileWhere is
|
|
10
|
+
* dialect-agnostic: the caller passes the drizzle operators (`and`, `or`,
|
|
11
|
+
* `eq`, `inArray`, ...) so the kernel has no drizzle dependency.
|
|
12
|
+
*
|
|
13
|
+
* Evaluation context: `row` (the entity row or action input), `user`
|
|
14
|
+
* ({ id, roles }), and `ownerField` (which row field owns the row —
|
|
15
|
+
* required to compile `owner`).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { CMP_OPS } from './expr.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {*} ast
|
|
22
|
+
* @param {{ row?: *, user?: { id?: *, roles?: string[] }, ownerField?: string }} ctx
|
|
23
|
+
* @returns {*}
|
|
24
|
+
*/
|
|
25
|
+
export function evalExpr(ast, ctx = {}) {
|
|
26
|
+
const { row = {}, user = {}, ownerField } = ctx;
|
|
27
|
+
function ev(node) {
|
|
28
|
+
if ('lit' in node) return node.lit;
|
|
29
|
+
if ('path' in node) {
|
|
30
|
+
let v = row;
|
|
31
|
+
for (const seg of node.path) v = v == null ? undefined : v[seg];
|
|
32
|
+
return v;
|
|
33
|
+
}
|
|
34
|
+
if (node.owner === true) {
|
|
35
|
+
if (!ownerField) throw new Error('cannot evaluate `owner` without ownerField');
|
|
36
|
+
return row?.[ownerField] === user?.id;
|
|
37
|
+
}
|
|
38
|
+
if ('role' in node) return !!user?.roles?.includes(node.role);
|
|
39
|
+
switch (node.op) {
|
|
40
|
+
case 'or':
|
|
41
|
+
return node.args.reduce((acc, a) => acc || !!ev(a), false);
|
|
42
|
+
case 'and':
|
|
43
|
+
return node.args.reduce((acc, a) => acc && !!ev(a), true);
|
|
44
|
+
case 'not':
|
|
45
|
+
return !ev(node.args[0]);
|
|
46
|
+
case '==':
|
|
47
|
+
return ev(node.args[0]) === ev(node.args[1]);
|
|
48
|
+
case '!=':
|
|
49
|
+
return ev(node.args[0]) !== ev(node.args[1]);
|
|
50
|
+
case '<':
|
|
51
|
+
return ev(node.args[0]) < ev(node.args[1]);
|
|
52
|
+
case '<=':
|
|
53
|
+
return ev(node.args[0]) <= ev(node.args[1]);
|
|
54
|
+
case '>':
|
|
55
|
+
return ev(node.args[0]) > ev(node.args[1]);
|
|
56
|
+
case '>=':
|
|
57
|
+
return ev(node.args[0]) >= ev(node.args[1]);
|
|
58
|
+
case 'in': {
|
|
59
|
+
const r = ev(node.args[1]);
|
|
60
|
+
return !!(r?.includes?.(ev(node.args[0])));
|
|
61
|
+
}
|
|
62
|
+
default:
|
|
63
|
+
throw new Error(`cannot evaluate node: ${JSON.stringify(node)}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return ev(ast);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const JS_CMP = { '==': '===', '!=': '!==', '<': '<', '<=': '<=', '>': '>', '>=': '>=' };
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Compile to a Civet/JS boolean expression over `row` and `user`.
|
|
73
|
+
* Semantics match evalExpr exactly.
|
|
74
|
+
*
|
|
75
|
+
* @param {*} ast
|
|
76
|
+
* @param {{ ownerField?: string }} [opts]
|
|
77
|
+
* @returns {string}
|
|
78
|
+
*/
|
|
79
|
+
export function compileGuard(ast, opts = {}) {
|
|
80
|
+
function emit(node) {
|
|
81
|
+
if ('lit' in node) return JSON.stringify(node.lit);
|
|
82
|
+
if ('path' in node) {
|
|
83
|
+
const [head, ...rest] = node.path;
|
|
84
|
+
return `row${rest.length ? '?' : ''}.${head}${rest.map((s) => `?.${s}`).join('')}`;
|
|
85
|
+
}
|
|
86
|
+
if (node.owner === true) {
|
|
87
|
+
if (!opts.ownerField) throw new Error('cannot compile `owner` without ownerField');
|
|
88
|
+
return `(row?.${opts.ownerField} === user?.id)`;
|
|
89
|
+
}
|
|
90
|
+
if ('role' in node) return `!!user?.roles?.includes(${JSON.stringify(node.role)})`;
|
|
91
|
+
switch (node.op) {
|
|
92
|
+
case 'or':
|
|
93
|
+
return `(${node.args.map((a) => `!!${emit(a)}`).join(' || ')})`;
|
|
94
|
+
case 'and':
|
|
95
|
+
return `(${node.args.map((a) => `!!${emit(a)}`).join(' && ')})`;
|
|
96
|
+
case 'not':
|
|
97
|
+
return `!(${emit(node.args[0])})`;
|
|
98
|
+
case 'in':
|
|
99
|
+
return `!!((${emit(node.args[1])})?.includes?.(${emit(node.args[0])}))`;
|
|
100
|
+
default: {
|
|
101
|
+
const op = JS_CMP[node.op];
|
|
102
|
+
if (!op) throw new Error(`cannot compile node: ${JSON.stringify(node)}`);
|
|
103
|
+
return `((${emit(node.args[0])}) ${op} (${emit(node.args[1])}))`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return emit(ast);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @typedef {{
|
|
112
|
+
* and: (...args: *) => *, or: (...args: *) => *, not: (arg: *) => *,
|
|
113
|
+
* eq: (col: *, v: *) => *, ne: (col: *, v: *) => *,
|
|
114
|
+
* lt: (col: *, v: *) => *, lte: (col: *, v: *) => *,
|
|
115
|
+
* gt: (col: *, v: *) => *, gte: (col: *, v: *) => *,
|
|
116
|
+
* inArray: (col: *, v: *[]) => *, bool: (v: boolean) => *
|
|
117
|
+
* }} WhereOps
|
|
118
|
+
*/
|
|
119
|
+
|
|
120
|
+
const FLIP = { '<': '>', '<=': '>=', '>': '<', '>=': '<=', '==': '==', '!=': '!=' };
|
|
121
|
+
const OP_FN = { '==': 'eq', '!=': 'ne', '<': 'lt', '<=': 'lte', '>': 'gt', '>=': 'gte' };
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Compile to a Drizzle where fragment. Supports the SQL-able subset:
|
|
125
|
+
* single-segment column paths compared against literals, `owner`, `role:`
|
|
126
|
+
* (resolved against `user` at build time) and boolean combinators. Anything
|
|
127
|
+
* beyond that (nested paths, path-vs-path comparison) throws — such rules
|
|
128
|
+
* belong in a guard, not a where clause.
|
|
129
|
+
*
|
|
130
|
+
* @param {*} ast
|
|
131
|
+
* @param {{ table: *, ops: WhereOps, user?: *, ownerField?: string }} ctx
|
|
132
|
+
*/
|
|
133
|
+
export function compileWhere(ast, ctx) {
|
|
134
|
+
const { table, ops, user = {}, ownerField } = ctx;
|
|
135
|
+
|
|
136
|
+
function column(path) {
|
|
137
|
+
if (path.length !== 1) {
|
|
138
|
+
throw new Error(`cannot compile nested path "${path.join('.')}" to a where clause`);
|
|
139
|
+
}
|
|
140
|
+
const col = table[path[0]];
|
|
141
|
+
if (col === undefined) throw new Error(`unknown column "${path[0]}"`);
|
|
142
|
+
return col;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function build(node) {
|
|
146
|
+
if ('lit' in node) return ops.bool(!!node.lit);
|
|
147
|
+
if ('path' in node) return ops.eq(column(node.path), true);
|
|
148
|
+
if (node.owner === true) {
|
|
149
|
+
if (!ownerField) throw new Error('cannot compile `owner` without ownerField');
|
|
150
|
+
return ops.eq(column([ownerField]), user?.id);
|
|
151
|
+
}
|
|
152
|
+
if ('role' in node) return ops.bool(!!user?.roles?.includes(node.role));
|
|
153
|
+
switch (node.op) {
|
|
154
|
+
case 'or':
|
|
155
|
+
return ops.or(...node.args.map(build));
|
|
156
|
+
case 'and':
|
|
157
|
+
return ops.and(...node.args.map(build));
|
|
158
|
+
case 'not':
|
|
159
|
+
return ops.not(build(node.args[0]));
|
|
160
|
+
default:
|
|
161
|
+
if (!CMP_OPS.includes(node.op)) {
|
|
162
|
+
throw new Error(`cannot compile node: ${JSON.stringify(node)}`);
|
|
163
|
+
}
|
|
164
|
+
return buildCmp(node);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function buildCmp({ op, args }) {
|
|
169
|
+
let [l, r] = args;
|
|
170
|
+
let effOp = op;
|
|
171
|
+
if ('lit' in l && 'path' in r && op !== 'in') {
|
|
172
|
+
[l, r] = [r, l];
|
|
173
|
+
effOp = FLIP[op];
|
|
174
|
+
}
|
|
175
|
+
if (!('path' in l) || !('lit' in r)) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`where clauses support column-vs-literal comparisons only, got ${op} over ${JSON.stringify(args)}`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
if (effOp === 'in') {
|
|
181
|
+
if (!Array.isArray(r.lit)) throw new Error('`in` in a where clause needs a list literal');
|
|
182
|
+
return ops.inArray(column(l.path), r.lit);
|
|
183
|
+
}
|
|
184
|
+
return ops[OP_FN[effOp]](column(l.path), r.lit);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return build(ast);
|
|
188
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CEL-style expression subset (PLAN §4.8) — used by Action `requires`,
|
|
3
|
+
* Query filters and Policy rules. One parser everywhere: the text form is
|
|
4
|
+
* what humans and agents write; the JSON AST is what gets stored and
|
|
5
|
+
* compiled (to Civet guards and Drizzle `where` clauses).
|
|
6
|
+
*
|
|
7
|
+
* Grammar:
|
|
8
|
+
* expr := or
|
|
9
|
+
* or := and ( "or" and )*
|
|
10
|
+
* and := not ( "and" not )*
|
|
11
|
+
* not := [ "not" ] cmp
|
|
12
|
+
* cmp := operand [ ( "==" | "!=" | "<" | "<=" | ">" | ">=" | "in" ) operand ]
|
|
13
|
+
* operand := literal | list | path | "owner" | "role:" ident | "(" expr ")"
|
|
14
|
+
* path := ident ( "." ident )*
|
|
15
|
+
* literal := number | string | true | false | null
|
|
16
|
+
* list := "[" [ literal ( "," literal )* ] "]"
|
|
17
|
+
*
|
|
18
|
+
* AST nodes:
|
|
19
|
+
* { op: 'or'|'and', args: [node, node, ...] } n-ary, flattened
|
|
20
|
+
* { op: 'not', args: [node] }
|
|
21
|
+
* { op: '=='|'!='|'<'|'<='|'>'|'>='|'in', args: [node, node] }
|
|
22
|
+
* { lit: number|string|boolean|null|literal[] }
|
|
23
|
+
* { path: [segment, ...] }
|
|
24
|
+
* { owner: true }
|
|
25
|
+
* { role: name }
|
|
26
|
+
*
|
|
27
|
+
* No side effects, no user-defined functions, no loops.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export const CMP_OPS = ['==', '!=', '<=', '>=', '<', '>', 'in'];
|
|
31
|
+
|
|
32
|
+
const KEYWORDS = new Set(['or', 'and', 'not', 'in', 'true', 'false', 'null', 'owner', 'role']);
|
|
33
|
+
const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*/;
|
|
34
|
+
const NUMBER_RE = /^-?\d+(\.\d+)?([eE][+-]?\d+)?/;
|
|
35
|
+
|
|
36
|
+
class ExprError extends Error {
|
|
37
|
+
constructor(message, pos, text) {
|
|
38
|
+
super(`${message} at position ${pos} in ${JSON.stringify(text)}`);
|
|
39
|
+
this.name = 'ExprError';
|
|
40
|
+
this.pos = pos;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function tokenize(text) {
|
|
45
|
+
const tokens = [];
|
|
46
|
+
let i = 0;
|
|
47
|
+
while (i < text.length) {
|
|
48
|
+
const ch = text[i];
|
|
49
|
+
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
|
|
50
|
+
i++;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if ('()[],.:'.includes(ch)) {
|
|
54
|
+
tokens.push({ type: ch, pos: i });
|
|
55
|
+
i++;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (ch === '=' || ch === '!' || ch === '<' || ch === '>') {
|
|
59
|
+
const two = text.slice(i, i + 2);
|
|
60
|
+
if (two === '==' || two === '!=' || two === '<=' || two === '>=') {
|
|
61
|
+
tokens.push({ type: 'cmp', value: two, pos: i });
|
|
62
|
+
i += 2;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (ch === '<' || ch === '>') {
|
|
66
|
+
tokens.push({ type: 'cmp', value: ch, pos: i });
|
|
67
|
+
i++;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
throw new ExprError(`unexpected "${ch}"`, i, text);
|
|
71
|
+
}
|
|
72
|
+
if (ch === '"') {
|
|
73
|
+
let j = i + 1;
|
|
74
|
+
while (j < text.length && text[j] !== '"') {
|
|
75
|
+
j += text[j] === '\\' ? 2 : 1;
|
|
76
|
+
}
|
|
77
|
+
if (j >= text.length) throw new ExprError('unterminated string', i, text);
|
|
78
|
+
let value;
|
|
79
|
+
try {
|
|
80
|
+
value = JSON.parse(text.slice(i, j + 1));
|
|
81
|
+
} catch {
|
|
82
|
+
throw new ExprError('invalid string escape', i, text);
|
|
83
|
+
}
|
|
84
|
+
tokens.push({ type: 'string', value, pos: i });
|
|
85
|
+
i = j + 1;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const num = NUMBER_RE.exec(text.slice(i));
|
|
89
|
+
if (num && (ch !== '-' || /\d/.test(text[i + 1] ?? ''))) {
|
|
90
|
+
tokens.push({ type: 'number', value: Number(num[0]), pos: i });
|
|
91
|
+
i += num[0].length;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const ident = IDENT_RE.exec(text.slice(i));
|
|
95
|
+
if (ident) {
|
|
96
|
+
const word = ident[0];
|
|
97
|
+
tokens.push({ type: KEYWORDS.has(word) ? word : 'ident', value: word, pos: i });
|
|
98
|
+
i += word.length;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
throw new ExprError(`unexpected "${ch}"`, i, text);
|
|
102
|
+
}
|
|
103
|
+
tokens.push({ type: 'eof', pos: text.length });
|
|
104
|
+
return tokens;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Parse an expression to its JSON AST.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} text
|
|
111
|
+
* @returns {*} AST node
|
|
112
|
+
*/
|
|
113
|
+
export function parseExpr(text) {
|
|
114
|
+
const tokens = tokenize(text);
|
|
115
|
+
let pos = 0;
|
|
116
|
+
|
|
117
|
+
const peek = () => tokens[pos];
|
|
118
|
+
const next = () => tokens[pos++];
|
|
119
|
+
const expect = (type) => {
|
|
120
|
+
const t = next();
|
|
121
|
+
if (t.type !== type) throw new ExprError(`expected "${type}", got "${t.type}"`, t.pos, text);
|
|
122
|
+
return t;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
function parseOr() {
|
|
126
|
+
const args = [parseAnd()];
|
|
127
|
+
while (peek().type === 'or') {
|
|
128
|
+
next();
|
|
129
|
+
args.push(parseAnd());
|
|
130
|
+
}
|
|
131
|
+
return args.length === 1 ? args[0] : { op: 'or', args };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function parseAnd() {
|
|
135
|
+
const args = [parseNot()];
|
|
136
|
+
while (peek().type === 'and') {
|
|
137
|
+
next();
|
|
138
|
+
args.push(parseNot());
|
|
139
|
+
}
|
|
140
|
+
return args.length === 1 ? args[0] : { op: 'and', args };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function parseNot() {
|
|
144
|
+
if (peek().type === 'not') {
|
|
145
|
+
next();
|
|
146
|
+
return { op: 'not', args: [parseCmp()] };
|
|
147
|
+
}
|
|
148
|
+
return parseCmp();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function parseCmp() {
|
|
152
|
+
const left = parseOperand();
|
|
153
|
+
const t = peek();
|
|
154
|
+
if (t.type === 'cmp' || t.type === 'in') {
|
|
155
|
+
next();
|
|
156
|
+
const op = t.type === 'in' ? 'in' : t.value;
|
|
157
|
+
return { op, args: [left, parseOperand()] };
|
|
158
|
+
}
|
|
159
|
+
return left;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function parseLiteral() {
|
|
163
|
+
const t = next();
|
|
164
|
+
if (t.type === 'number' || t.type === 'string') return { lit: t.value };
|
|
165
|
+
if (t.type === 'true') return { lit: true };
|
|
166
|
+
if (t.type === 'false') return { lit: false };
|
|
167
|
+
if (t.type === 'null') return { lit: null };
|
|
168
|
+
throw new ExprError(`expected literal, got "${t.type}"`, t.pos, text);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function parseOperand() {
|
|
172
|
+
const t = peek();
|
|
173
|
+
switch (t.type) {
|
|
174
|
+
case '(': {
|
|
175
|
+
next();
|
|
176
|
+
const inner = parseOr();
|
|
177
|
+
expect(')');
|
|
178
|
+
return inner;
|
|
179
|
+
}
|
|
180
|
+
case '[': {
|
|
181
|
+
next();
|
|
182
|
+
const items = [];
|
|
183
|
+
if (peek().type !== ']') {
|
|
184
|
+
items.push(parseLiteral().lit);
|
|
185
|
+
while (peek().type === ',') {
|
|
186
|
+
next();
|
|
187
|
+
items.push(parseLiteral().lit);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
expect(']');
|
|
191
|
+
return { lit: items };
|
|
192
|
+
}
|
|
193
|
+
case 'owner':
|
|
194
|
+
next();
|
|
195
|
+
return { owner: true };
|
|
196
|
+
case 'role': {
|
|
197
|
+
next();
|
|
198
|
+
expect(':');
|
|
199
|
+
const name = next();
|
|
200
|
+
if (name.type !== 'ident') {
|
|
201
|
+
throw new ExprError('expected role name after "role:"', name.pos, text);
|
|
202
|
+
}
|
|
203
|
+
return { role: name.value };
|
|
204
|
+
}
|
|
205
|
+
case 'ident': {
|
|
206
|
+
const segments = [next().value];
|
|
207
|
+
while (peek().type === '.') {
|
|
208
|
+
next();
|
|
209
|
+
const seg = next();
|
|
210
|
+
if (seg.type !== 'ident') {
|
|
211
|
+
throw new ExprError('expected path segment after "."', seg.pos, text);
|
|
212
|
+
}
|
|
213
|
+
segments.push(seg.value);
|
|
214
|
+
}
|
|
215
|
+
return { path: segments };
|
|
216
|
+
}
|
|
217
|
+
case 'number':
|
|
218
|
+
case 'string':
|
|
219
|
+
case 'true':
|
|
220
|
+
case 'false':
|
|
221
|
+
case 'null':
|
|
222
|
+
return parseLiteral();
|
|
223
|
+
default:
|
|
224
|
+
throw new ExprError(`unexpected "${t.type}"`, t.pos, text);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const ast = parseOr();
|
|
229
|
+
const end = peek();
|
|
230
|
+
if (end.type !== 'eof') throw new ExprError(`unexpected trailing "${end.type}"`, end.pos, text);
|
|
231
|
+
return ast;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Precedence levels: 1 or · 2 and · 3 not · 4 cmp · 5 atom.
|
|
235
|
+
function levelOf(node) {
|
|
236
|
+
if (node.op === 'or') return 1;
|
|
237
|
+
if (node.op === 'and') return 2;
|
|
238
|
+
if (node.op === 'not') return 3;
|
|
239
|
+
if (CMP_OPS.includes(node.op)) return 4;
|
|
240
|
+
return 5;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Print an AST back to canonical text. `parseExpr(printExpr(ast))` is the
|
|
245
|
+
* identity on canonical ASTs; parens are emitted only where the grammar
|
|
246
|
+
* demands them.
|
|
247
|
+
*
|
|
248
|
+
* @param {*} node AST node
|
|
249
|
+
* @returns {string}
|
|
250
|
+
*/
|
|
251
|
+
export function printExpr(node, min = 1) {
|
|
252
|
+
const wrap = (text, level) => (level < min ? `(${text})` : text);
|
|
253
|
+
if (node === null || typeof node !== 'object') {
|
|
254
|
+
throw new Error(`invalid expression node: ${JSON.stringify(node)}`);
|
|
255
|
+
}
|
|
256
|
+
if ('lit' in node) {
|
|
257
|
+
return Array.isArray(node.lit)
|
|
258
|
+
? `[${node.lit.map((v) => JSON.stringify(v)).join(', ')}]`
|
|
259
|
+
: JSON.stringify(node.lit);
|
|
260
|
+
}
|
|
261
|
+
if ('path' in node) return node.path.join('.');
|
|
262
|
+
if (node.owner === true) return 'owner';
|
|
263
|
+
if ('role' in node) return `role:${node.role}`;
|
|
264
|
+
switch (node.op) {
|
|
265
|
+
case 'or':
|
|
266
|
+
return wrap(node.args.map((a) => printExpr(a, 2)).join(' or '), 1);
|
|
267
|
+
case 'and':
|
|
268
|
+
return wrap(node.args.map((a) => printExpr(a, 3)).join(' and '), 2);
|
|
269
|
+
case 'not':
|
|
270
|
+
return wrap(`not ${printExpr(node.args[0], 4)}`, 3);
|
|
271
|
+
default:
|
|
272
|
+
if (!CMP_OPS.includes(node.op)) {
|
|
273
|
+
throw new Error(`invalid expression node: ${JSON.stringify(node)}`);
|
|
274
|
+
}
|
|
275
|
+
return wrap(
|
|
276
|
+
`${printExpr(node.args[0], 5)} ${node.op} ${printExpr(node.args[1], 5)}`,
|
|
277
|
+
4
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** True when `text` parses as a valid expression. */
|
|
283
|
+
export function isExpr(text) {
|
|
284
|
+
try {
|
|
285
|
+
parseExpr(text);
|
|
286
|
+
return true;
|
|
287
|
+
} catch {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
}
|