@terrazzo/parser 0.3.2 → 0.3.4

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,20 +1,19 @@
1
- import { type DocumentNode, type ObjectNode, evaluate, parse as parseJSON, print } from '@humanwhocodes/momoa';
2
- import { type Token, type TokenNormalized, isTokenMatch, pluralize, splitID } from '@terrazzo/token-tools';
1
+ import type { DocumentNode, ObjectNode } from '@humanwhocodes/momoa';
2
+ import { type Token, type TokenNormalized, pluralize, splitID } from '@terrazzo/token-tools';
3
3
  import type ytm from 'yaml-to-momoa';
4
4
  import lintRunner from '../lint/index.js';
5
5
  import Logger from '../logger.js';
6
6
  import type { ConfigInit, InputSource } from '../types.js';
7
7
  import { applyAliases } from './alias.js';
8
- import { getObjMembers, injectObjMembers, maybeJSONString, traverse } from './json.js';
8
+ import { getObjMembers, toMomoa, traverse } from './json.js';
9
9
  import normalize from './normalize.js';
10
- import validate from './validate.js';
10
+ import validateTokenNode from './validate.js';
11
11
 
12
12
  export * from './alias.js';
13
- export { default as normalize } from './normalize.js';
14
13
  export * from './normalize.js';
15
14
  export * from './json.js';
16
- export { default as validate } from './validate.js';
17
15
  export * from './validate.js';
16
+ export { normalize, validateTokenNode };
18
17
 
19
18
  export interface ParseOptions {
20
19
  logger?: Logger;
@@ -31,6 +30,8 @@ export interface ParseOptions {
31
30
  continueOnError?: boolean;
32
31
  /** Provide yamlToMomoa module to parse YAML (by default, this isn’t shipped to cut down on package weight) */
33
32
  yamlToMomoa?: typeof ytm;
33
+ /** (internal cache; do not use) */
34
+ _sources?: Record<string, InputSource>;
34
35
  }
35
36
 
36
37
  export interface ParseResult {
@@ -47,53 +48,59 @@ export default async function parse(
47
48
  config = {} as ConfigInit,
48
49
  continueOnError = false,
49
50
  yamlToMomoa,
51
+ _sources = {},
50
52
  }: ParseOptions = {} as ParseOptions,
51
53
  ): Promise<ParseResult> {
52
54
  let tokens: Record<string, TokenNormalized> = {};
53
- // note: only keeps track of sources with locations on disk; in-memory sources are discarded
54
- // (it’s only for reporting line numbers, which doesn’t mean as much for dynamic sources)
55
- const sources: Record<string, InputSource> = {};
56
55
 
57
56
  if (!Array.isArray(input)) {
58
57
  logger.error({ group: 'parser', label: 'init', message: 'Input must be an array of input objects.' });
59
58
  }
60
- for (let i = 0; i < input.length; i++) {
61
- if (!input[i] || typeof input[i] !== 'object') {
62
- logger.error({ group: 'parser', label: 'init', message: `Input (${i}) must be an object.` });
63
- }
64
- if (!input[i]!.src || (typeof input[i]!.src !== 'string' && typeof input[i]!.src !== 'object')) {
65
- logger.error({
66
- group: 'parser',
67
- label: 'init',
68
- message: `Input (${i}) missing "src" with a JSON/YAML string, or JSON object.`,
69
- });
70
- }
71
- if (input[i]!.filename && !(input[i]!.filename instanceof URL)) {
72
- logger.error({
73
- group: 'parser',
74
- label: 'init',
75
- message: `Input (${i}) "filename" must be a URL (remote or file URL).`,
76
- });
77
- }
59
+ await Promise.all(
60
+ input.map(async (src, i) => {
61
+ if (!src || typeof src !== 'object') {
62
+ logger.error({ group: 'parser', label: 'init', message: `Input (${i}) must be an object.` });
63
+ }
64
+ if (!src.src || (typeof src.src !== 'string' && typeof src.src !== 'object')) {
65
+ logger.error({
66
+ message: `Input (${i}) missing "src" with a JSON/YAML string, or JSON object.`,
67
+ group: 'parser',
68
+ label: 'init',
69
+ });
70
+ }
71
+ if (src.filename) {
72
+ if (!(src.filename instanceof URL)) {
73
+ logger.error({
74
+ message: `Input (${i}) "filename" must be a URL (remote or file URL).`,
75
+ group: 'parser',
76
+ label: 'init',
77
+ });
78
+ }
78
79
 
79
- const result = await parseSingle(input[i]!.src, {
80
- filename: input[i]!.filename!,
81
- logger,
82
- config,
83
- skipLint,
84
- continueOnError,
85
- yamlToMomoa,
86
- });
80
+ // if already parsed/scanned, skip
81
+ if (_sources[src.filename.href]) {
82
+ return;
83
+ }
84
+ }
87
85
 
88
- tokens = Object.assign(tokens, result.tokens);
89
- if (input[i]!.filename) {
90
- sources[input[i]!.filename!.protocol === 'file:' ? input[i]!.filename!.href : input[i]!.filename!.href] = {
91
- filename: input[i]!.filename,
92
- src: result.src,
93
- document: result.document,
94
- };
95
- }
96
- }
86
+ const result = await parseSingle(src.src, {
87
+ filename: src.filename!,
88
+ logger,
89
+ config,
90
+ skipLint,
91
+ continueOnError,
92
+ yamlToMomoa,
93
+ });
94
+ tokens = Object.assign(tokens, result.tokens);
95
+ if (src.filename) {
96
+ _sources[src.filename.href] = {
97
+ filename: src.filename,
98
+ src: result.src,
99
+ document: result.document,
100
+ };
101
+ }
102
+ }),
103
+ );
97
104
 
98
105
  const totalStart = performance.now();
99
106
 
@@ -104,8 +111,8 @@ export default async function parse(
104
111
  }
105
112
  applyAliases(token, {
106
113
  tokens,
107
- filename: sources[token.source.loc!]?.filename!,
108
- src: sources[token.source.loc!]?.src as string,
114
+ filename: _sources[token.source.loc!]?.filename!,
115
+ src: _sources[token.source.loc!]?.src as string,
109
116
  node: token.source.node,
110
117
  logger,
111
118
  });
@@ -130,9 +137,9 @@ export default async function parse(
130
137
  // 6. resolve mode aliases
131
138
  const modesStart = performance.now();
132
139
  logger.debug({
140
+ message: 'Start mode resolution',
133
141
  group: 'parser',
134
142
  label: 'modes',
135
- message: 'Start mode resolution',
136
143
  });
137
144
  for (const [id, token] of Object.entries(tokens)) {
138
145
  if (!Object.hasOwn(tokens, id)) {
@@ -146,7 +153,7 @@ export default async function parse(
146
153
  tokens,
147
154
  node: modeValue.source.node,
148
155
  logger,
149
- src: sources[token.source.loc!]?.src as string,
156
+ src: _sources[token.source.loc!]?.src as string,
150
157
  });
151
158
  }
152
159
  }
@@ -158,9 +165,9 @@ export default async function parse(
158
165
  });
159
166
 
160
167
  logger.debug({
168
+ message: 'Finish all parser tasks',
161
169
  group: 'parser',
162
170
  label: 'core',
163
- message: 'Finish all parser tasks',
164
171
  timing: performance.now() - totalStart,
165
172
  });
166
173
 
@@ -175,7 +182,7 @@ export default async function parse(
175
182
 
176
183
  return {
177
184
  tokens,
178
- sources: Object.values(sources),
185
+ sources: Object.values(_sources),
179
186
  };
180
187
  }
181
188
 
@@ -197,185 +204,26 @@ async function parseSingle(
197
204
  continueOnError?: boolean;
198
205
  yamlToMomoa?: typeof ytm;
199
206
  },
200
- ) {
207
+ ): Promise<{ tokens: Record<string, Token>; document: DocumentNode; src?: string }> {
201
208
  // 1. Build AST
202
- let src: any;
203
- if (typeof input === 'string') {
204
- src = input;
205
- }
206
209
  const startParsing = performance.now();
207
- logger.debug({ group: 'parser', label: 'parse', message: 'Start tokens parsing' });
208
- let document = {} as DocumentNode;
209
- if (typeof input === 'string' && !maybeJSONString(input)) {
210
- if (yamlToMomoa) {
211
- try {
212
- document = yamlToMomoa(input); // if string, but not JSON, attempt YAML
213
- } catch (err) {
214
- logger.error({ message: String(err), filename, src: input, continueOnError });
215
- }
216
- } else {
217
- logger.error({
218
- group: 'parser',
219
- message: `Install \`yaml-to-momoa\` package to parse YAML, and pass in as option, e.g.:
220
-
221
- import { parse } from '@terrazzo/parser';
222
- import yamlToMomoa from 'yaml-to-momoa';
223
-
224
- parse(yamlString, { yamlToMomoa });`,
225
- continueOnError: false, // fail here; no point in continuing
226
- });
227
- }
228
- } else {
229
- document = parseJSON(
230
- typeof input === 'string' ? input : JSON.stringify(input, undefined, 2), // everything else: assert it’s JSON-serializable
231
- {
232
- mode: 'jsonc',
233
- ranges: true,
234
- tokens: true,
235
- },
236
- );
237
- }
238
- if (!src) {
239
- src = print(document, { indent: 2 });
240
- }
210
+ logger.debug({ group: 'parser', label: 'parse', message: 'Start JSON parsing' });
211
+ const { src, document } = toMomoa(input, { filename, logger, continueOnError, yamlToMomoa });
241
212
  logger.debug({
242
213
  group: 'parser',
243
214
  label: 'parse',
244
- message: 'Finish tokens parsing',
215
+ message: 'Finish JSON parsing',
245
216
  timing: performance.now() - startParsing,
246
217
  });
247
-
248
218
  const tokens: Record<string, TokenNormalized> = {};
249
219
 
250
- // 2. Walk AST once to validate tokens
251
- const startValidation = performance.now();
252
- logger.debug({ group: 'parser', label: 'validate', message: 'Start tokens validation' });
220
+ // 2. Walk AST to validate tokens
221
+ const startValidate = performance.now();
253
222
  const $typeInheritance: Record<string, Token['$type']> = {};
223
+ logger.debug({ message: 'Start token validation', group: 'parser', label: 'validate' });
254
224
  traverse(document, {
255
- enter(node, parent, path) {
256
- if (node.type === 'Member' && node.value.type === 'Object' && node.value.members && !path.includes('$value')) {
257
- const members = getObjMembers(node.value);
258
-
259
- // keep track of $types
260
- if (members.$type && members.$type.type === 'String' && !members.$value) {
261
- // @ts-ignore
262
- $typeInheritance[path.join('.') || '.'] = node.value.members.find((m) => m.name.value === '$type');
263
- }
264
-
265
- const id = path.join('.');
266
-
267
- if (
268
- members.$value &&
269
- !path.includes('$extensions') // don’t validate anything in $extensions
270
- ) {
271
- const extensions = members.$extensions ? getObjMembers(members.$extensions as ObjectNode) : undefined;
272
- const sourceNode = structuredClone(node);
273
-
274
- // get parent type by taking the closest-scoped $type (length === closer)
275
- let parent$type: Token['$type'] | undefined;
276
- let longestPath = '';
277
- for (const [k, v] of Object.entries($typeInheritance)) {
278
- if (k === '.' || id.startsWith(k)) {
279
- if (k.length > longestPath.length) {
280
- parent$type = v;
281
- longestPath = k;
282
- }
283
- }
284
- }
285
- if (parent$type && !members.$type) {
286
- sourceNode.value = injectObjMembers(
287
- // @ts-ignore
288
- sourceNode.value,
289
- [parent$type],
290
- );
291
- }
292
-
293
- validate(sourceNode, { filename, src, logger });
294
-
295
- // All tokens must be valid, so we want to validate it up till this
296
- // point. However, if we are ignoring this token (or respecting
297
- // $deprecated, we can omit it from the output.
298
- const $deprecated = members.$deprecated && (evaluate(members.$deprecated) as string | boolean | undefined);
299
- if (
300
- (config.ignore.deprecated && $deprecated) ||
301
- (config.ignore.tokens && isTokenMatch(id, config.ignore.tokens))
302
- ) {
303
- return;
304
- }
305
-
306
- const group: TokenNormalized['group'] = { id: splitID(id).group!, tokens: [] };
307
- if (parent$type) {
308
- group.$type =
309
- // @ts-ignore
310
- parent$type.value.value;
311
- }
312
- // note: this will also include sibling tokens, so be selective about only accessing group-specific properties
313
- const groupMembers = getObjMembers(
314
- // @ts-ignore
315
- parent,
316
- );
317
- if (groupMembers.$description) {
318
- group.$description = evaluate(groupMembers.$description) as string;
319
- }
320
- if (groupMembers.$extensions) {
321
- group.$extensions = evaluate(groupMembers.$extensions) as Record<string, unknown>;
322
- }
323
- const token: TokenNormalized = {
324
- // @ts-ignore
325
- $type: members.$type?.value ?? parent$type?.value.value,
326
- // @ts-ignore
327
- $value: evaluate(members.$value),
328
- id,
329
- // @ts-ignore
330
- mode: {},
331
- // @ts-ignore
332
- originalValue: evaluate(node.value),
333
- group,
334
- source: {
335
- loc: filename ? filename.href : undefined,
336
- // @ts-ignore
337
- node: sourceNode.value,
338
- },
339
- };
340
- // @ts-ignore
341
- if (members.$description?.value) {
342
- // @ts-ignore
343
- token.$description = members.$description.value;
344
- }
345
-
346
- // handle modes
347
- // note that circular refs are avoided here, such as not duplicating `modes`
348
- const modeValues = extensions?.mode
349
- ? getObjMembers(
350
- // @ts-ignore
351
- extensions.mode,
352
- )
353
- : {};
354
- for (const mode of ['.', ...Object.keys(modeValues)]) {
355
- token.mode[mode] = {
356
- id: token.id,
357
- // @ts-ignore
358
- $type: token.$type,
359
- // @ts-ignore
360
- $value: mode === '.' ? token.$value : evaluate(modeValues[mode]),
361
- source: {
362
- loc: filename ? filename.href : undefined,
363
- // @ts-ignore
364
- node: mode === '.' ? structuredClone(token.source.node) : modeValues[mode],
365
- },
366
- };
367
- if (token.$description) {
368
- token.mode[mode]!.$description = token.$description;
369
- }
370
- }
371
-
372
- tokens[id] = token;
373
- } else if (!path.includes('.$value') && members.value) {
374
- logger.warn({ message: `Group ${id} has "value". Did you mean "$value"?`, filename, node, src });
375
- }
376
- }
377
-
378
- // edge case: if $type appears at root of tokens.json, collect it
225
+ enter(node, parent, subpath) {
226
+ // if $type appears at root of tokens.json, collect it
379
227
  if (node.type === 'Document' && node.body.type === 'Object' && node.body.members) {
380
228
  const members = getObjMembers(node.body);
381
229
  if (members.$type && members.$type.type === 'String' && !members.$value) {
@@ -383,21 +231,24 @@ async function parseSingle(
383
231
  $typeInheritance['.'] = node.body.members.find((m) => m.name.value === '$type');
384
232
  }
385
233
  }
234
+
235
+ // handle tokens
236
+ if (node.type === 'Member') {
237
+ const token = validateTokenNode(node, { filename, src, config, logger, parent, subpath, $typeInheritance });
238
+ if (token) {
239
+ tokens[token.id] = token;
240
+ }
241
+ }
386
242
  },
387
243
  });
388
- logger.debug({
389
- group: 'parser',
390
- label: 'validate',
391
- message: 'Finish tokens validation',
392
- timing: performance.now() - startValidation,
393
- });
244
+ logger.debug({ message: 'Finish token validation', group: 'parser', label: 'validate', timing: startValidate });
394
245
 
395
246
  // 3. normalize values
396
247
  const normalizeStart = performance.now();
397
248
  logger.debug({
249
+ message: 'Start token normalization',
398
250
  group: 'parser',
399
251
  label: 'normalize',
400
- message: 'Start token normalization',
401
252
  });
402
253
  for (const [id, token] of Object.entries(tokens)) {
403
254
  try {
@@ -422,13 +273,7 @@ async function parseSingle(
422
273
  if (members.$value) {
423
274
  node = members.$value as ObjectNode;
424
275
  }
425
- logger.error({
426
- message: (err as Error).message,
427
- filename,
428
- src,
429
- node: modeValue.source.node,
430
- continueOnError,
431
- });
276
+ logger.error({ message: (err as Error).message, filename, src, node: modeValue.source.node, continueOnError });
432
277
  }
433
278
  }
434
279
  }
@@ -436,24 +281,20 @@ async function parseSingle(
436
281
  // 4. Execute lint runner with loaded plugins
437
282
  if (!skipLint && config?.plugins?.length) {
438
283
  const lintStart = performance.now();
439
- logger.debug({
440
- group: 'parser',
441
- label: 'validate',
442
- message: 'Start token linting',
443
- });
284
+ logger.debug({ message: 'Start token linting', group: 'parser', label: 'validate' });
444
285
  await lintRunner({ tokens, src, config, logger });
445
286
  logger.debug({
287
+ message: 'Finish token linting',
446
288
  group: 'parser',
447
289
  label: 'validate',
448
- message: 'Finish token linting',
449
290
  timing: performance.now() - lintStart,
450
291
  });
451
292
  }
452
293
 
453
294
  logger.debug({
295
+ message: 'Finish token normalization',
454
296
  group: 'parser',
455
297
  label: 'normalize',
456
- message: 'Finish token normalization',
457
298
  timing: performance.now() - normalizeStart,
458
299
  });
459
300
 
package/src/parse/json.ts CHANGED
@@ -1,4 +1,15 @@
1
- import type { AnyNode, MemberNode, ObjectNode, ValueNode } from '@humanwhocodes/momoa';
1
+ import {
2
+ type AnyNode,
3
+ type DocumentNode,
4
+ type MemberNode,
5
+ type ObjectNode,
6
+ type ValueNode,
7
+ parse as parseJSON,
8
+ print,
9
+ } from '@humanwhocodes/momoa';
10
+ import type yamlToMomoa from 'yaml-to-momoa';
11
+ import type Logger from '../logger.js';
12
+ import type { InputSource } from '../types.js';
2
13
 
3
14
  export interface Visitor {
4
15
  enter?: (node: AnyNode, parent: AnyNode | undefined, path: string[]) => void;
@@ -25,17 +36,20 @@ export function isNode(value: unknown): boolean {
25
36
  return !!value && typeof value === 'object' && 'type' in value && typeof value.type === 'string';
26
37
  }
27
38
 
39
+ export type ValueNodeWithIndex = ValueNode & { index: number };
40
+
28
41
  /** Get ObjectNode members as object */
29
- export function getObjMembers(node: ObjectNode): Record<string | number, ValueNode> {
30
- const members: Record<string | number, ValueNode> = {};
42
+ export function getObjMembers(node: ObjectNode): Record<string | number, ValueNodeWithIndex> {
43
+ const members: Record<string | number, ValueNodeWithIndex> = {};
31
44
  if (node.type !== 'Object') {
32
45
  return members;
33
46
  }
34
- for (const m of node.members) {
47
+ for (let i = 0; i < node.members.length; i++) {
48
+ const m = node.members[i]!;
35
49
  if (m.name.type !== 'String') {
36
50
  continue;
37
51
  }
38
- members[m.name.value] = m.value;
52
+ members[m.name.value] = { ...m.value, index: i };
39
53
  }
40
54
  return members;
41
55
  }
@@ -56,7 +70,8 @@ export function injectObjMembers(node: ObjectNode, members: MemberNode[] = []):
56
70
  }
57
71
 
58
72
  /**
59
- * Variation of Momoa’s traverse(), which keeps track of global path
73
+ * Variation of Momoa’s traverse(), which keeps track of global path.
74
+ * Allows mutation of AST (along with any consequences)
60
75
  */
61
76
  export function traverse(root: AnyNode, visitor: Visitor) {
62
77
  /**
@@ -101,6 +116,92 @@ export function traverse(root: AnyNode, visitor: Visitor) {
101
116
  }
102
117
 
103
118
  /** Determine if an input is likely a JSON string */
104
- export function maybeJSONString(input: string): boolean {
119
+ export function maybeRawJSON(input: string): boolean {
105
120
  return typeof input === 'string' && input.trim().startsWith('{');
106
121
  }
122
+
123
+ /** Find Momoa node by traversing paths */
124
+ export function findNode<T = AnyNode>(node: AnyNode, path: string[]): T | undefined {
125
+ if (!path.length) {
126
+ return;
127
+ }
128
+
129
+ let nextNode: AnyNode | undefined;
130
+
131
+ switch (node.type) {
132
+ // for Document nodes, dive into body for “free” (not part of the path)
133
+ case 'Document': {
134
+ return findNode(node.body, path);
135
+ }
136
+ case 'Object': {
137
+ const [member, ...rest] = path;
138
+ nextNode = node.members.find((m) => m.name.type === 'String' && m.name.value === member)?.value;
139
+ if (nextNode && rest.length) {
140
+ return findNode(nextNode, path.slice(1));
141
+ }
142
+ break;
143
+ }
144
+ case 'Array': {
145
+ const [_index, ...rest] = path;
146
+ const index = Number.parseInt(_index!, 10);
147
+ nextNode = node.elements[index]?.value;
148
+ if (nextNode && rest.length) {
149
+ return findNode(nextNode, path.slice(1));
150
+ }
151
+ break;
152
+ }
153
+ }
154
+
155
+ return nextNode as T;
156
+ }
157
+
158
+ export interface ToMomoaOptions {
159
+ filename?: URL;
160
+ continueOnError?: boolean;
161
+ logger: Logger;
162
+ yamlToMomoa?: typeof yamlToMomoa;
163
+ }
164
+
165
+ export function toMomoa(
166
+ input: string | Record<string, any>,
167
+ { continueOnError, filename, logger, yamlToMomoa }: ToMomoaOptions,
168
+ ): InputSource {
169
+ let src = '';
170
+ if (typeof input === 'string') {
171
+ src = input;
172
+ }
173
+ let document = {} as DocumentNode;
174
+ if (typeof input === 'string' && !maybeRawJSON(input)) {
175
+ if (yamlToMomoa) {
176
+ try {
177
+ document = yamlToMomoa(input); // if string, but not JSON, attempt YAML
178
+ } catch (err) {
179
+ logger.error({ message: String(err), filename, src: input, continueOnError });
180
+ }
181
+ } else {
182
+ logger.error({
183
+ group: 'parser',
184
+ message: `Install \`yaml-to-momoa\` package to parse YAML, and pass in as option, e.g.:
185
+
186
+ import { parse } from '@terrazzo/parser';
187
+ import yamlToMomoa from 'yaml-to-momoa';
188
+
189
+ parse(yamlString, { yamlToMomoa });`,
190
+ continueOnError: false, // fail here; no point in continuing
191
+ });
192
+ }
193
+ } else {
194
+ document = parseJSON(
195
+ typeof input === 'string' ? input : JSON.stringify(input, undefined, 2), // everything else: assert it’s JSON-serializable
196
+ {
197
+ mode: 'jsonc',
198
+ ranges: true,
199
+ tokens: true,
200
+ },
201
+ );
202
+ }
203
+ if (!src) {
204
+ src = print(document, { indent: 2 });
205
+ }
206
+ return { src, document };
207
+ }