@shapeshift-labs/frontier-lang-parser 0.3.43 → 0.3.45

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.
@@ -1,4 +1,5 @@
1
1
  import { readFrontierNestedBlocks } from './source-syntax-report.js';
2
+ import { readActionValue } from './action-expression.js';
2
3
 
3
4
  export function readActionBodyRecords(body) {
4
5
  const records = [];
@@ -27,7 +28,8 @@ function parseActionBodyRecords(source, state) {
27
28
  const close = findMatchingBrace(source, open);
28
29
  if (close < 0) break;
29
30
  const index = state.index++;
30
- records.push(parseActionIfBlock(ifHeader[1].trim(), source.slice(open + 1, close), index, state));
31
+ const record = parseActionIfBlock(ifHeader[1].trim(), source.slice(open + 1, close), index, state);
32
+ if (record) records.push(record);
31
33
  offset = close + 1;
32
34
  continue;
33
35
  }
@@ -45,6 +47,7 @@ function parseActionBodyRecords(source, state) {
45
47
 
46
48
  function parseActionIfBlock(header, body, index, state) {
47
49
  const details = parseActionIfHeader(header, index);
50
+ if (!details.condition) return undefined;
48
51
  return compactRecord({
49
52
  kind: 'if',
50
53
  id: details.id,
@@ -74,20 +77,32 @@ function parseActionBodyLine(line, index) {
74
77
  const name = rawName && !rawName.startsWith('@') ? rawName : `${rowKind}_${index}`;
75
78
  const rest = rawName?.startsWith('@') ? ` ${rawName}${rawRest ?? ''}` : (rawRest ?? '');
76
79
  if (rowKind === 'set' || rowKind === 'insert' || rowKind === 'merge') {
77
- return compactRecord({ kind: 'patch', op: rowKind, id: idFrom(rest, `action_body_${rowKind}_${name}`), name, path: readInlineWord('path', rest), value: readInlineActionValue('value', rest) });
80
+ const path = readInlineWord('path', rest);
81
+ const value = readInlineActionValue('value', rest);
82
+ if (!path || !value) return undefined;
83
+ return compactRecord({ kind: 'patch', op: rowKind, id: idFrom(rest, `action_body_${rowKind}_${name}`), name, path, value });
78
84
  }
79
85
  if (rowKind === 'remove') {
80
- return compactRecord({ kind: 'patch', op: 'remove', id: idFrom(rest, `action_body_remove_${name}`), name, path: readInlineWord('path', rest) });
86
+ const path = readInlineWord('path', rest);
87
+ if (!path) return undefined;
88
+ return compactRecord({ kind: 'patch', op: 'remove', id: idFrom(rest, `action_body_remove_${name}`), name, path });
81
89
  }
82
90
  if (rowKind === 'callEffect') {
83
- return compactRecord({ kind: 'callEffect', id: idFrom(rest, `action_body_callEffect_${name}`), name, capability: readInlineWord('capability', rest) ?? readInlineWord('effect', rest) ?? name, input: readInlineActionValue('input', rest) });
91
+ const inputText = readInlineValue('input', rest);
92
+ const input = inputText ? readActionValue(inputText) : undefined;
93
+ if (inputText && !input) return undefined;
94
+ return compactRecord({ kind: 'callEffect', id: idFrom(rest, `action_body_callEffect_${name}`), name, capability: readInlineWord('capability', rest) ?? readInlineWord('effect', rest) ?? name, input });
84
95
  }
85
96
  if (rowKind === 'let') {
86
- return compactRecord({ kind: 'let', id: idFrom(rest, `action_body_let_${name}`), name, value: readInlineActionValue('value', rest) });
97
+ const value = readInlineActionBindingValue('value', rest);
98
+ if (!rawName || rawName.startsWith('@') || !isActionBindingName(name) || !value) return undefined;
99
+ return compactRecord({ kind: 'let', id: idFrom(rest, `action_body_let_${name}`), name, value });
87
100
  }
88
101
  if (rowKind === 'return') {
89
- const valueText = rawName?.startsWith('@') ? rest.trim() : `${rawName ?? ''}${rest ?? ''}`.trim();
90
- return compactRecord({ kind: 'return', id: idFrom(rest, `action_body_return_${index}`), value: valueText ? readActionValue(valueText) : undefined });
102
+ const valueText = stripIds(rawName?.startsWith('@') ? rest : `${rawName ?? ''}${rest ?? ''}`).trim();
103
+ const value = valueText ? readActionValue(valueText) : undefined;
104
+ if (valueText && !value) return undefined;
105
+ return compactRecord({ kind: 'return', id: idFrom(rest, `action_body_return_${index}`), value });
91
106
  }
92
107
  return undefined;
93
108
  }
@@ -157,22 +172,22 @@ function findMatchingBrace(source, open) {
157
172
  }
158
173
 
159
174
  function readInlineActionValue(label, text) {
160
- const value = new RegExp('(?:^|\\s)' + label + '\\s+(.+?)(?=\\s+[A-Za-z_$][\\w$-]*\\s+|$)').exec(text)?.[1]?.trim();
175
+ const value = readInlineValue(label, text);
161
176
  return value ? readActionValue(value) : undefined;
162
177
  }
163
178
 
164
- function readActionValue(value) {
165
- const text = value.trim();
166
- const quoted = /^["']([^"']*)["']$/.exec(text);
167
- if (quoted) return { value: quoted[1] };
168
- if (text === 'true') return { value: true };
169
- if (text === 'false') return { value: false };
170
- if (text === 'null') return { value: null };
171
- if (/^-?\d+(?:\.\d+)?$/.test(text)) return { value: Number(text) };
172
- return { expression: text };
179
+ function readInlineActionBindingValue(label, text) {
180
+ const value = readInlineActionValue(label, text);
181
+ return value ?? undefined;
182
+ }
183
+
184
+ function isActionBindingName(value) {
185
+ return /^[A-Za-z_$][\w$-]*$/.test(String(value ?? ''));
173
186
  }
174
187
 
175
188
  function idFrom(header, fallback) { return /@id\(\s*["']([^"']+)["']\s*\)/.exec(header)?.[1] ?? fallback; }
176
189
  function readInlineWord(label, text) { return new RegExp('(?:^|\\s)' + label + '\\s+([^\\s,]+)').exec(text)?.[1]?.trim(); }
190
+ function readInlineValue(label, text) { return new RegExp('(?:^|\\s)' + label + '\\s+(.+?)(?=\\s+[A-Za-z_$][\\w$-]*\\s+|$)').exec(text)?.[1]?.trim(); }
191
+ function stripIds(text) { return String(text ?? '').replace(/@id\(\s*["'][^"']+["']\s*\)/g, '').trim(); }
177
192
  function firstIdentifier(text) { return /^([A-Za-z_$][\w$-]*)\b/.exec(text)?.[1]; }
178
193
  function compactRecord(record) { return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined && (!Array.isArray(value) || value.length > 0))); }
@@ -0,0 +1,266 @@
1
+ const IDENTIFIER = /^[A-Za-z_$][\w$-]*$/;
2
+ const BLOCKED_REF_ROOTS = new Set(['globalThis', 'process', 'window', 'document', 'constructor', 'prototype', '__proto__', 'env']);
3
+
4
+ export function readActionValue(value, options = {}) {
5
+ const parsed = parseActionValue(value, options);
6
+ return parsed.ok ? parsed.value : undefined;
7
+ }
8
+
9
+ export function parseActionValue(value, options = {}) {
10
+ const text = String(value ?? '').trim();
11
+ if (!text) return fail('missing-action-expression');
12
+ const expression = parseActionExpression(text, options);
13
+ if (!expression.ok) return expression;
14
+ if (expression.literal) return { ok: true, value: { value: expression.ast.value } };
15
+ return { ok: true, value: { expression: text, expressionAst: expression.ast } };
16
+ }
17
+
18
+ export function parseActionExpression(value, options = {}) {
19
+ const text = String(value ?? '').trim();
20
+ if (!text) return fail('missing-action-expression');
21
+ const tokens = tokenizeActionExpression(text);
22
+ if (!tokens.ok) return tokens;
23
+ const parser = new ExpressionParser(tokens.tokens, options);
24
+ const ast = parser.parseExpression();
25
+ if (!parser.ok) return fail(parser.reason);
26
+ if (parser.peek().type !== 'eof') return fail(parser.reasonForTrailingToken());
27
+ return { ok: true, ast, literal: ast.kind === 'literal' };
28
+ }
29
+
30
+ export function isSupportedActionValue(value, options = {}) {
31
+ return parseActionValue(value, options).ok;
32
+ }
33
+
34
+ function tokenizeActionExpression(text) {
35
+ const tokens = [];
36
+ let index = 0;
37
+ while (index < text.length) {
38
+ const char = text[index];
39
+ if (/\s/.test(char)) {
40
+ index++;
41
+ continue;
42
+ }
43
+ if (char === '"' || char === "'") {
44
+ const read = readStringToken(text, index);
45
+ if (!read.ok) return read;
46
+ tokens.push(read.token);
47
+ index = read.next;
48
+ continue;
49
+ }
50
+ const number = /^-?\d+(?:\.\d+)?/.exec(text.slice(index));
51
+ if (number) {
52
+ tokens.push({ type: 'number', value: Number(number[0]), text: number[0], start: index, end: index + number[0].length });
53
+ index += number[0].length;
54
+ continue;
55
+ }
56
+ const identifier = /^[A-Za-z_$][\w$-]*/.exec(text.slice(index));
57
+ if (identifier) {
58
+ tokens.push({ type: 'identifier', text: identifier[0], start: index, end: index + identifier[0].length });
59
+ index += identifier[0].length;
60
+ continue;
61
+ }
62
+ const triple = text.slice(index, index + 3);
63
+ if (triple === '===' || triple === '!==') return fail('unsupported-action-expression-operator');
64
+ const double = text.slice(index, index + 2);
65
+ if (double === '&&' || double === '||' || double === '==' || double === '!=' || double === '>=' || double === '<=') {
66
+ tokens.push({ type: 'operator', text: double, start: index, end: index + 2 });
67
+ index += 2;
68
+ continue;
69
+ }
70
+ if (char === '!' || char === '>' || char === '<') {
71
+ tokens.push({ type: 'operator', text: char, start: index, end: index + 1 });
72
+ index++;
73
+ continue;
74
+ }
75
+ if (char === '(' || char === ')' || char === '.') {
76
+ tokens.push({ type: 'punctuation', text: char, start: index, end: index + 1 });
77
+ index++;
78
+ continue;
79
+ }
80
+ if ('+-*/%=&|?:[]{}'.includes(char)) return fail('unsupported-action-expression-operator');
81
+ return fail('malformed-action-expression');
82
+ }
83
+ tokens.push({ type: 'eof', text: '', start: text.length, end: text.length });
84
+ return { ok: true, tokens };
85
+ }
86
+
87
+ function readStringToken(text, start) {
88
+ const quote = text[start];
89
+ let value = '';
90
+ let index = start + 1;
91
+ while (index < text.length) {
92
+ const char = text[index];
93
+ if (char === quote) {
94
+ return { ok: true, token: { type: 'string', value, text: text.slice(start, index + 1), start, end: index + 1 }, next: index + 1 };
95
+ }
96
+ if (char === '\\') {
97
+ const next = text[index + 1];
98
+ if (next === undefined) return fail('malformed-action-expression');
99
+ if (next === 'n') value += '\n';
100
+ else if (next === 'r') value += '\r';
101
+ else if (next === 't') value += '\t';
102
+ else if (next === '\\' || next === '"' || next === "'") value += next;
103
+ else return fail('malformed-action-expression');
104
+ index += 2;
105
+ continue;
106
+ }
107
+ value += char;
108
+ index++;
109
+ }
110
+ return fail('malformed-action-expression');
111
+ }
112
+
113
+ class ExpressionParser {
114
+ constructor(tokens, options) {
115
+ this.tokens = tokens;
116
+ this.options = options;
117
+ this.index = 0;
118
+ this.ok = true;
119
+ this.reason = undefined;
120
+ }
121
+
122
+ parseExpression() {
123
+ return this.parseLogicalOr();
124
+ }
125
+
126
+ parseLogicalOr() {
127
+ let left = this.parseLogicalAnd();
128
+ while (this.matchOperator('||')) left = { kind: 'logical', op: '||', left, right: this.parseLogicalAnd() };
129
+ return left;
130
+ }
131
+
132
+ parseLogicalAnd() {
133
+ let left = this.parseComparison();
134
+ while (this.matchOperator('&&')) left = { kind: 'logical', op: '&&', left, right: this.parseComparison() };
135
+ return left;
136
+ }
137
+
138
+ parseComparison() {
139
+ let left = this.parseUnary();
140
+ const operator = this.peek();
141
+ if (operator.type === 'operator' && ['==', '!=', '>', '>=', '<', '<='].includes(operator.text)) {
142
+ this.index++;
143
+ left = { kind: 'binary', op: operator.text, left, right: this.parseUnary() };
144
+ if (this.peek().type === 'operator' && ['==', '!=', '>', '>=', '<', '<='].includes(this.peek().text)) {
145
+ this.reject('malformed-action-expression');
146
+ }
147
+ }
148
+ return left;
149
+ }
150
+
151
+ parseUnary() {
152
+ if (this.matchOperator('!')) return { kind: 'unary', op: '!', argument: this.parseUnary() };
153
+ return this.parsePrimary();
154
+ }
155
+
156
+ parsePrimary() {
157
+ const token = this.peek();
158
+ if (token.type === 'number') {
159
+ this.index++;
160
+ return { kind: 'literal', value: token.value };
161
+ }
162
+ if (token.type === 'string') {
163
+ this.index++;
164
+ return { kind: 'literal', value: token.value };
165
+ }
166
+ if (token.type === 'identifier') {
167
+ if (token.text === 'true' || token.text === 'false') {
168
+ this.index++;
169
+ return { kind: 'literal', value: token.text === 'true' };
170
+ }
171
+ if (token.text === 'null') {
172
+ this.index++;
173
+ return { kind: 'literal', value: null };
174
+ }
175
+ return this.parseRef();
176
+ }
177
+ if (token.type === 'punctuation' && token.text === '(') {
178
+ this.index++;
179
+ const expression = this.parseExpression();
180
+ if (!this.matchPunctuation(')')) this.reject('malformed-action-expression');
181
+ return expression;
182
+ }
183
+ this.reject('malformed-action-expression');
184
+ return { kind: 'literal', value: null };
185
+ }
186
+
187
+ parseRef() {
188
+ const parts = [];
189
+ const first = this.consumeIdentifier();
190
+ if (!first) return { kind: 'literal', value: null };
191
+ parts.push(first);
192
+ while (this.matchPunctuation('.')) {
193
+ const part = this.consumeIdentifier();
194
+ if (!part) {
195
+ this.reject('unsupported-action-expression-ref');
196
+ return refNode(parts);
197
+ }
198
+ parts.push(part);
199
+ }
200
+ if (this.peek().type === 'punctuation' && this.peek().text === '(') {
201
+ this.reject('unsupported-action-expression-ref');
202
+ return refNode(parts);
203
+ }
204
+ if (BLOCKED_REF_ROOTS.has(parts[0])) {
205
+ this.reject('unsupported-action-expression-ref');
206
+ return refNode(parts);
207
+ }
208
+ return refNode(parts);
209
+ }
210
+
211
+ consumeIdentifier() {
212
+ const token = this.peek();
213
+ if (token.type !== 'identifier' || !IDENTIFIER.test(token.text)) {
214
+ this.reject('unsupported-action-expression-ref');
215
+ return undefined;
216
+ }
217
+ this.index++;
218
+ return token.text;
219
+ }
220
+
221
+ matchOperator(operator) {
222
+ const token = this.peek();
223
+ if (token.type === 'operator' && token.text === operator) {
224
+ this.index++;
225
+ return true;
226
+ }
227
+ return false;
228
+ }
229
+
230
+ matchPunctuation(text) {
231
+ const token = this.peek();
232
+ if (token.type === 'punctuation' && token.text === text) {
233
+ this.index++;
234
+ return true;
235
+ }
236
+ return false;
237
+ }
238
+
239
+ peek() {
240
+ return this.tokens[this.index] ?? this.tokens[this.tokens.length - 1];
241
+ }
242
+
243
+ reject(reason) {
244
+ if (!this.ok) return;
245
+ this.ok = false;
246
+ this.reason = reason;
247
+ }
248
+
249
+ reasonForTrailingToken() {
250
+ const token = this.peek();
251
+ if (token.type === 'operator') return 'unsupported-action-expression-operator';
252
+ if (token.type === 'punctuation' && (token.text === '.' || token.text === '(' || token.text === '[')) return 'unsupported-action-expression-ref';
253
+ return 'malformed-action-expression';
254
+ }
255
+ }
256
+
257
+ function refNode(parts) {
258
+ const [root, ...rest] = parts;
259
+ const scope = root === 'input' || root === 'state' || root === 'patches' ? root : 'local';
260
+ const path = scope === 'local' ? parts : rest;
261
+ return { kind: 'ref', name: parts.join('.'), scope, path };
262
+ }
263
+
264
+ function fail(reason) {
265
+ return { ok: false, reason };
266
+ }
@@ -1,3 +1,5 @@
1
+ import { parseActionValue } from './action-expression.js';
2
+
1
3
  const ACTION_BODY_ROWS = new Set(['set', 'insert', 'remove', 'merge', 'callEffect', 'return', 'if', 'let']);
2
4
 
3
5
  export function readActionSyntaxChildren(source, block, options) {
@@ -56,7 +58,8 @@ function actionSyntaxChild(source, block, options, line, state, parentActionBody
56
58
  const rowKind = row?.[1] ?? 'unknown';
57
59
  const name = actionRowName(rowKind, row?.[2], rowIndex);
58
60
  const rest = row?.[2]?.startsWith('@') ? ` ${row[2]}${row[3] ?? ''}` : (row?.[3] ?? '');
59
- const recognized = ACTION_BODY_ROWS.has(rowKind);
61
+ const validation = validateActionRow(rowKind, row?.[2], rest, line.text);
62
+ const recognized = ACTION_BODY_ROWS.has(rowKind) && validation.ok;
60
63
  return cleanRecord({
61
64
  kind: recognized ? 'actionBodyRow' : 'actionUnknownRow',
62
65
  rowKind,
@@ -75,7 +78,7 @@ function actionSyntaxChild(source, block, options, line, state, parentActionBody
75
78
  parentActionBodyId,
76
79
  sourceSpan: sourceSpan(source, block, line.startOffset, line.endOffset, options),
77
80
  recognized,
78
- reason: recognized ? undefined : 'unsupported-action-body-row'
81
+ reason: recognized ? undefined : validation.reason
79
82
  });
80
83
  }
81
84
 
@@ -86,6 +89,63 @@ function actionRowName(rowKind, rawName, rowIndex) {
86
89
  return rawName && !rawName.startsWith('@') ? rawName : `${rowKind}_${rowIndex}`;
87
90
  }
88
91
 
92
+ function validateActionRow(rowKind, rawName, rest, header) {
93
+ if (!ACTION_BODY_ROWS.has(rowKind)) return { ok: false, reason: 'unsupported-action-body-row' };
94
+ if (rowKind === 'if') return validateActionExpressionText(readIfCondition(header));
95
+ if (rowKind === 'set' || rowKind === 'insert' || rowKind === 'merge') {
96
+ if (!readInlineWord('path', rest)) return { ok: false, reason: 'missing-action-path' };
97
+ return validateActionExpressionText(readInlineValue('value', rest));
98
+ }
99
+ if (rowKind === 'remove') {
100
+ return readInlineWord('path', rest) ? { ok: true } : { ok: false, reason: 'missing-action-path' };
101
+ }
102
+ if (rowKind === 'callEffect') {
103
+ const input = readInlineValue('input', rest);
104
+ return input ? validateActionExpressionText(input) : { ok: true };
105
+ }
106
+ if (rowKind === 'return') {
107
+ const value = readReturnValue(rawName, rest);
108
+ return value ? validateActionExpressionText(value) : { ok: true };
109
+ }
110
+ if (rowKind === 'let') {
111
+ if (!rawName || rawName.startsWith('@') || !/^[A-Za-z_$][\w$-]*$/.test(rawName)) {
112
+ return { ok: false, reason: 'unsupported-action-binding-name' };
113
+ }
114
+ const value = readInlineValue('value', rest);
115
+ const parsed = value ? parseActionValue(value) : undefined;
116
+ return parsed?.ok ? { ok: true } : { ok: false, reason: 'unsupported-action-binding-value' };
117
+ }
118
+ return { ok: true };
119
+ }
120
+
121
+ function readInlineValue(label, text) {
122
+ return new RegExp('(?:^|\\s)' + label + '\\s+(.+?)(?=\\s+[A-Za-z_$][\\w$-]*\\s+|$)').exec(text)?.[1]?.trim();
123
+ }
124
+
125
+ function readInlineWord(label, text) {
126
+ return new RegExp('(?:^|\\s)' + label + '\\s+([^\\s,]+)').exec(text)?.[1]?.trim();
127
+ }
128
+
129
+ function readIfCondition(header) {
130
+ const text = header.replace(/^if\b/, '').replace(/\{\s*$/, '').replace(/@id\(\s*["'][^"']+["']\s*\)/g, '').trim();
131
+ const explicit = /\bcondition\s+(.+)$/.exec(text);
132
+ return explicit ? explicit[1].trim() : text;
133
+ }
134
+
135
+ function readReturnValue(rawName, rest) {
136
+ if (rawName?.startsWith('@')) return stripIds(`${rawName}${rest ?? ''}`).trim();
137
+ return stripIds(`${rawName ?? ''}${rest ?? ''}`).trim();
138
+ }
139
+
140
+ function stripIds(text) {
141
+ return String(text ?? '').replace(/@id\(\s*["'][^"']+["']\s*\)/g, '').trim();
142
+ }
143
+
144
+ function validateActionExpressionText(text) {
145
+ const parsed = parseActionValue(text);
146
+ return parsed.ok ? { ok: true } : { ok: false, reason: parsed.reason };
147
+ }
148
+
89
149
  function readNestedBodyBlocks(kind, source) {
90
150
  const blocks = [];
91
151
  const header = new RegExp('\\b' + kind.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?:\\s+([^{}\\n]+?))?\\s*\\{', 'g');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shapeshift-labs/frontier-lang-parser",
3
- "version": "0.3.43",
3
+ "version": "0.3.45",
4
4
  "description": "Parser for the first Frontier Lang .frontier syntax slice.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",