cddl2ts 0.7.1 → 0.8.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/README.md CHANGED
@@ -23,6 +23,12 @@ This package exposes a CLI as well as a programmatic interface for transforming
23
23
  npx cddl2ts ./path/to/interface.cddl &> ./path/to/interface.ts
24
24
  ```
25
25
 
26
+ Generated interface fields default to `camelCase`. Pass `--field-case snake` to emit `snake_case` fields while keeping exported interface and type names unchanged.
27
+
28
+ ```sh
29
+ npx cddl2ts ./path/to/interface.cddl --field-case snake &> ./path/to/interface.ts
30
+ ```
31
+
26
32
  ### Programmatic Interface
27
33
 
28
34
  The module exports a `transform` method that takes a CDDL AST object and returns a TypeScript definition as `string`, e.g.:
@@ -41,16 +47,16 @@ import { parse, transform } from 'cddl'
41
47
  * };
42
48
  */
43
49
  const ast = parse('./spec.cddl')
44
- const ts = transform(ast)
50
+ const ts = transform(ast, { fieldCase: 'snake' })
45
51
  console.log(ts)
46
52
  /**
47
53
  * outputs:
48
54
  *
49
55
  * interface SessionCapabilityRequest {
50
- * acceptInsecureCerts?: boolean,
51
- * browserName?: string,
52
- * browserVersion?: string,
53
- * platformName?: string,
56
+ * accept_insecure_certs?: boolean,
57
+ * browser_name?: string,
58
+ * browser_version?: string,
59
+ * platform_name?: string,
54
60
  * }
55
61
  */
56
62
  ```
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AASA,wBAA8B,GAAG,CAAE,IAAI,WAAwB,sBAgC9D"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAWA,wBAA8B,GAAG,CAAE,IAAI,WAAwB,sBAyC9D"}
package/build/cli.js CHANGED
@@ -4,6 +4,7 @@ import yargs from 'yargs';
4
4
  import { parse } from 'cddl';
5
5
  import { transform } from './index.js';
6
6
  import { pkg } from './constants.js';
7
+ const FIELD_CASE_CHOICES = ['camel', 'snake'];
7
8
  export default async function cli(argv = process.argv.slice(2)) {
8
9
  const parser = yargs(argv)
9
10
  .usage(`${pkg.name}\n${pkg.description}\n\nUsage:\nrunme2ts ./path/to/spec.cddl &> ./path/to/interface.ts`)
@@ -14,6 +15,12 @@ export default async function cli(argv = process.argv.slice(2)) {
14
15
  type: 'boolean',
15
16
  description: 'Use unknown instead of any',
16
17
  default: false
18
+ })
19
+ .option('field-case', {
20
+ choices: FIELD_CASE_CHOICES,
21
+ type: 'string',
22
+ description: 'Case for generated interface fields',
23
+ default: 'camel'
17
24
  })
18
25
  .help('help')
19
26
  .alias('h', 'help')
@@ -30,5 +37,8 @@ export default async function cli(argv = process.argv.slice(2)) {
30
37
  return process.exit(1);
31
38
  }
32
39
  const ast = parse(absoluteFilePath);
33
- console.log(transform(ast, { useUnknown: args.u }));
40
+ console.log(transform(ast, {
41
+ useUnknown: args.u,
42
+ fieldCase: args.fieldCase
43
+ }));
34
44
  }
package/build/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { type Assignment } from 'cddl';
2
+ export type FieldCase = 'camel' | 'snake';
2
3
  export interface TransformOptions {
3
4
  useUnknown?: boolean;
5
+ fieldCase?: FieldCase;
4
6
  }
5
7
  export declare function transform(assignments: Assignment[], options?: TransformOptions): string;
6
8
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,OAAO,EAWH,KAAK,UAAU,EAMlB,MAAM,MAAM,CAAA;AAyCb,MAAM,WAAW,gBAAgB;IAC7B,UAAU,CAAC,EAAE,OAAO,CAAA;CACvB;AAED,wBAAgB,SAAS,CAAE,WAAW,EAAE,UAAU,EAAE,EAAE,OAAO,CAAC,EAAE,gBAAgB,UAwB/E"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,OAAO,EAWH,KAAK,UAAU,EAMlB,MAAM,MAAM,CAAA;AAwCb,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,OAAO,CAAA;AAIzC,MAAM,WAAW,gBAAgB;IAC7B,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,SAAS,CAAC,EAAE,SAAS,CAAA;CACxB;AAED,wBAAgB,SAAS,CAAE,WAAW,EAAE,UAAU,EAAE,EAAE,OAAO,CAAC,EAAE,gBAAgB,UA8B/E"}
package/build/index.js CHANGED
@@ -36,8 +36,14 @@ const RECORD_KEY_TYPES = new Set([
36
36
  'float', 'float16', 'float32', 'float64', 'float16-32', 'float32-64',
37
37
  'str', 'text', 'tstr'
38
38
  ]);
39
+ const IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
39
40
  export function transform(assignments, options) {
40
- if (options?.useUnknown) {
41
+ const transformOptions = {
42
+ useUnknown: false,
43
+ fieldCase: 'camel',
44
+ ...options
45
+ };
46
+ if (transformOptions.useUnknown) {
41
47
  NATIVE_TYPES.any = b.tsUnknownKeyword();
42
48
  }
43
49
  else {
@@ -49,7 +55,7 @@ export function transform(assignments, options) {
49
55
  sourceRoot: process.cwd()
50
56
  });
51
57
  for (const assignment of assignments) {
52
- const statement = parseAssignment(assignment);
58
+ const statement = parseAssignment(assignment, transformOptions);
53
59
  if (!statement) {
54
60
  continue;
55
61
  }
@@ -57,7 +63,42 @@ export function transform(assignments, options) {
57
63
  }
58
64
  return print(ast).code;
59
65
  }
60
- function parseAssignment(assignment) {
66
+ function getAssignmentComments(assignment) {
67
+ return assignment.Comments.map((c) => b.commentLine(` ${c.Content}`, true));
68
+ }
69
+ function exportWithComments(declaration) {
70
+ const expr = b.exportDeclaration(false, declaration);
71
+ expr.comments = declaration.comments;
72
+ declaration.comments = [];
73
+ return expr;
74
+ }
75
+ function isExtensibleRecordProperty(prop) {
76
+ return !isUnNamedProperty(prop) &&
77
+ prop.Occurrence.m === Infinity &&
78
+ !prop.HasCut &&
79
+ RECORD_KEY_TYPES.has(prop.Name);
80
+ }
81
+ function toSnakeCase(name) {
82
+ return name
83
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
84
+ .replace(/([A-Z])([A-Z][a-z])/g, '$1_$2')
85
+ .replace(/[^A-Za-z0-9_$]+/g, '_')
86
+ .replace(/_+/g, '_')
87
+ .replace(/^_+|_+$/g, '')
88
+ .toLowerCase();
89
+ }
90
+ function formatFieldName(name, fieldCase) {
91
+ return fieldCase === 'snake'
92
+ ? toSnakeCase(name)
93
+ : camelcase(name);
94
+ }
95
+ function createPropertyKey(name, options) {
96
+ const fieldName = formatFieldName(name, options.fieldCase);
97
+ return IDENTIFIER_PATTERN.test(fieldName)
98
+ ? b.identifier(fieldName)
99
+ : b.stringLiteral(fieldName);
100
+ }
101
+ function parseAssignment(assignment, options) {
61
102
  if (isVariable(assignment)) {
62
103
  const propType = Array.isArray(assignment.PropertyType)
63
104
  ? assignment.PropertyType
@@ -69,11 +110,11 @@ function parseAssignment(assignment) {
69
110
  typeParameters = b.tsNumberKeyword();
70
111
  }
71
112
  else {
72
- typeParameters = b.tsUnionType(propType.map(parseUnionType));
113
+ typeParameters = b.tsUnionType(propType.map((prop) => parseUnionType(prop, options)));
73
114
  }
74
115
  const expr = b.tsTypeAliasDeclaration(id, typeParameters);
75
- expr.comments = assignment.Comments.map((c) => b.commentLine(` ${c.Content}`, true));
76
- return b.exportDeclaration(false, expr);
116
+ expr.comments = getAssignmentComments(assignment);
117
+ return exportWithComments(expr);
77
118
  }
78
119
  if (isGroup(assignment)) {
79
120
  const id = b.identifier(pascalCase(assignment.Name));
@@ -99,7 +140,7 @@ function parseAssignment(assignment) {
99
140
  choiceOptions.push(nextProp);
100
141
  i++; // Skip next property
101
142
  }
102
- const options = choiceOptions.map(p => {
143
+ const choiceTypes = choiceOptions.map(p => {
103
144
  // If p is a group reference (Name ''), it's a TypeReference
104
145
  // e.g. SessionAutodetectProxyConfiguration // SessionDirectProxyConfiguration
105
146
  // The parser sometimes wraps it in an array, sometimes not (if inside a choice)
@@ -110,12 +151,12 @@ function parseAssignment(assignment) {
110
151
  if (isNamedGroupReference(typeVal)) {
111
152
  return b.tsTypeReference(b.identifier(pascalCase(typeVal.Value || typeVal.Type)));
112
153
  }
113
- return parseUnionType(typeVal);
154
+ return parseUnionType(typeVal, options);
114
155
  }
115
156
  // Otherwise it is an object literal with this property
116
- return b.tsTypeLiteral(parseObjectType([p]));
157
+ return b.tsTypeLiteral(parseObjectType([p], options));
117
158
  });
118
- intersections.push(b.tsUnionType(options));
159
+ intersections.push(b.tsUnionType(choiceTypes));
119
160
  }
120
161
  else {
121
162
  staticProps.push(prop);
@@ -126,12 +167,12 @@ function parseAssignment(assignment) {
126
167
  const mixins = staticProps.filter(isUnNamedProperty);
127
168
  const ownProps = staticProps.filter(p => !isUnNamedProperty(p));
128
169
  if (ownProps.length > 0) {
129
- intersections.unshift(b.tsTypeLiteral(parseObjectType(ownProps)));
170
+ intersections.unshift(b.tsTypeLiteral(parseObjectType(ownProps, options)));
130
171
  }
131
172
  for (const mixin of mixins) {
132
173
  if (Array.isArray(mixin.Type) && mixin.Type.length > 1) {
133
- const options = mixin.Type.map(parseUnionType);
134
- intersections.push(b.tsUnionType(options));
174
+ const choices = mixin.Type.map((type) => parseUnionType(type, options));
175
+ intersections.push(b.tsUnionType(choices));
135
176
  }
136
177
  else {
137
178
  const typeVal = Array.isArray(mixin.Type) ? mixin.Type[0] : mixin.Type;
@@ -154,8 +195,8 @@ function parseAssignment(assignment) {
154
195
  value = b.tsIntersectionType(intersections);
155
196
  }
156
197
  const expr = b.tsTypeAliasDeclaration(id, value);
157
- expr.comments = assignment.Comments.map((c) => b.commentLine(` ${c.Content}`, true));
158
- return b.exportDeclaration(false, expr);
198
+ expr.comments = getAssignmentComments(assignment);
199
+ return exportWithComments(expr);
159
200
  }
160
201
  const props = properties;
161
202
  /**
@@ -165,10 +206,10 @@ function parseAssignment(assignment) {
165
206
  const prop = props[0];
166
207
  const propType = Array.isArray(prop.Type) ? prop.Type : [prop.Type];
167
208
  if (propType.length === 1 && RECORD_KEY_TYPES.has(prop.Name)) {
168
- const value = parseUnionType(assignment);
209
+ const value = parseUnionType(assignment, options);
169
210
  const expr = b.tsTypeAliasDeclaration(id, value);
170
- expr.comments = assignment.Comments.map((c) => b.commentLine(` ${c.Content}`, true));
171
- return b.exportDeclaration(false, expr);
211
+ expr.comments = getAssignmentComments(assignment);
212
+ return exportWithComments(expr);
172
213
  }
173
214
  }
174
215
  // Check if extended interfaces are likely unions or conflicting types
@@ -210,10 +251,10 @@ function parseAssignment(assignment) {
210
251
  const choices = [];
211
252
  for (const prop of group.Properties) {
212
253
  // Choices are wrapped in arrays in the properties
213
- const options = Array.isArray(prop) ? prop : [prop];
214
- if (options.length > 1) { // It's a choice within the mixin group
254
+ const choiceProps = Array.isArray(prop) ? prop : [prop];
255
+ if (choiceProps.length > 1) { // It's a choice within the mixin group
215
256
  const unionOptions = [];
216
- for (const option of options) {
257
+ for (const option of choiceProps) {
217
258
  let refName;
218
259
  const type = option.Type;
219
260
  if (typeof type === 'string')
@@ -245,7 +286,7 @@ function parseAssignment(assignment) {
245
286
  continue;
246
287
  }
247
288
  }
248
- for (const option of options) {
289
+ for (const option of choiceProps) {
249
290
  let refName;
250
291
  const type = option.Type;
251
292
  if (typeof type === 'string') {
@@ -340,7 +381,7 @@ function parseAssignment(assignment) {
340
381
  }
341
382
  else if (type && typeof type === 'object') {
342
383
  if (isGroup(type) && Array.isArray(type.Properties)) {
343
- choices.push(b.tsTypeLiteral(parseObjectType(type.Properties)));
384
+ choices.push(b.tsTypeLiteral(parseObjectType(type.Properties, options)));
344
385
  continue;
345
386
  }
346
387
  refName = isNamedGroupReference(type)
@@ -375,7 +416,7 @@ function parseAssignment(assignment) {
375
416
  }
376
417
  const ownProps = props.filter(p => !isUnNamedProperty(p));
377
418
  if (ownProps.length > 0) {
378
- intersections.push(b.tsTypeLiteral(parseObjectType(ownProps)));
419
+ intersections.push(b.tsTypeLiteral(parseObjectType(ownProps, options)));
379
420
  }
380
421
  let value;
381
422
  if (intersections.length === 1) {
@@ -385,14 +426,14 @@ function parseAssignment(assignment) {
385
426
  value = b.tsIntersectionType(intersections);
386
427
  }
387
428
  const expr = b.tsTypeAliasDeclaration(id, value);
388
- expr.comments = assignment.Comments.map((c) => b.commentLine(` ${c.Content}`, true));
389
- return b.exportDeclaration(false, expr);
429
+ expr.comments = getAssignmentComments(assignment);
430
+ return exportWithComments(expr);
390
431
  }
391
432
  // Fallback to interface if no mixins (pure object)
392
- const objectType = parseObjectType(props);
433
+ const objectType = parseObjectType(props, options);
393
434
  const expr = b.tsInterfaceDeclaration(id, b.tsInterfaceBody(objectType));
394
- expr.comments = assignment.Comments.map((c) => b.commentLine(` ${c.Content}`, true));
395
- return b.exportDeclaration(false, expr);
435
+ expr.comments = getAssignmentComments(assignment);
436
+ return exportWithComments(expr);
396
437
  }
397
438
  if (isCDDLArray(assignment)) {
398
439
  const id = b.identifier(pascalCase(assignment.Name));
@@ -404,30 +445,30 @@ function parseAssignment(assignment) {
404
445
  // We need to parse each choice.
405
446
  const obj = assignmentValues.map((prop) => {
406
447
  const t = Array.isArray(prop.Type) ? prop.Type[0] : prop.Type;
407
- return parseUnionType(t);
448
+ return parseUnionType(t, options);
408
449
  });
409
450
  const value = b.tsArrayType(b.tsParenthesizedType(b.tsUnionType(obj)));
410
451
  const expr = b.tsTypeAliasDeclaration(id, value);
411
- expr.comments = assignment.Comments.map((c) => b.commentLine(` ${c.Content}`, true));
412
- return b.exportDeclaration(false, expr);
452
+ expr.comments = getAssignmentComments(assignment);
453
+ return exportWithComments(expr);
413
454
  }
414
455
  // Standard array
415
456
  const firstType = assignmentValues.Type;
416
457
  const obj = Array.isArray(firstType)
417
- ? firstType.map(parseUnionType)
458
+ ? firstType.map((type) => parseUnionType(type, options))
418
459
  : isCDDLArray(firstType)
419
- ? firstType.Values.map((val) => parseUnionType(Array.isArray(val.Type) ? val.Type[0] : val.Type))
420
- : [parseUnionType(firstType)];
460
+ ? firstType.Values.map((val) => parseUnionType(Array.isArray(val.Type) ? val.Type[0] : val.Type, options))
461
+ : [parseUnionType(firstType, options)];
421
462
  const value = b.tsArrayType(obj.length === 1
422
463
  ? obj[0]
423
464
  : b.tsParenthesizedType(b.tsUnionType(obj)));
424
465
  const expr = b.tsTypeAliasDeclaration(id, value);
425
- expr.comments = assignment.Comments.map((c) => b.commentLine(` ${c.Content}`, true));
426
- return b.exportDeclaration(false, expr);
466
+ expr.comments = getAssignmentComments(assignment);
467
+ return exportWithComments(expr);
427
468
  }
428
469
  throw new Error(`Unknown assignment type "${assignment.Type}"`);
429
470
  }
430
- function parseObjectType(props) {
471
+ function parseObjectType(props, options) {
431
472
  const propItems = [];
432
473
  for (const prop of props) {
433
474
  /**
@@ -444,16 +485,29 @@ function parseObjectType(props) {
444
485
  if (isUnNamedProperty(prop)) {
445
486
  continue;
446
487
  }
447
- const id = b.identifier(camelcase(prop.Name));
448
488
  const cddlType = Array.isArray(prop.Type) ? prop.Type : [prop.Type];
449
489
  const comments = prop.Comments.map((c) => ` ${c.Content}`);
490
+ if (isExtensibleRecordProperty(prop)) {
491
+ const keyIdentifier = b.identifier('key');
492
+ keyIdentifier.typeAnnotation = b.tsTypeAnnotation(NATIVE_TYPES[prop.Name]);
493
+ const indexSignature = b.tsIndexSignature([keyIdentifier], b.tsTypeAnnotation(b.tsUnionType([
494
+ ...cddlType.map((t) => parseUnionType(t, options)),
495
+ b.tsUndefinedKeyword()
496
+ ])));
497
+ indexSignature.comments = comments.length
498
+ ? [b.commentBlock(`*\n *${comments.join('\n *')}\n `)]
499
+ : [];
500
+ propItems.push(indexSignature);
501
+ continue;
502
+ }
503
+ const id = createPropertyKey(prop.Name, options);
450
504
  if (prop.Operator && prop.Operator.Type === 'default') {
451
505
  const defaultValue = parseDefaultValue(prop.Operator);
452
506
  defaultValue && comments.length && comments.push(''); // add empty line if we have previous comments
453
507
  defaultValue && comments.push(` @default ${defaultValue}`);
454
508
  }
455
509
  const type = cddlType.map((t) => {
456
- const unionType = parseUnionType(t);
510
+ const unionType = parseUnionType(t, options);
457
511
  if (unionType) {
458
512
  const defaultValue = parseDefaultValue(t.Operator);
459
513
  defaultValue && comments.length && comments.push(''); // add empty line if we have previous comments
@@ -470,7 +524,7 @@ function parseObjectType(props) {
470
524
  }
471
525
  return propItems;
472
526
  }
473
- function parseUnionType(t) {
527
+ function parseUnionType(t, options) {
474
528
  if (typeof t === 'string') {
475
529
  if (!NATIVE_TYPES[t]) {
476
530
  throw new Error(`Unknown native type: "${t}`);
@@ -496,31 +550,31 @@ function parseUnionType(t) {
496
550
  * Check if we have choices in the group (arrays of Properties)
497
551
  */
498
552
  if (prop.some(p => Array.isArray(p))) {
499
- const options = [];
553
+ const choices = [];
500
554
  for (const choice of prop) {
501
555
  const subProps = Array.isArray(choice) ? choice : [choice];
502
556
  if (subProps.length === 1 && isUnNamedProperty(subProps[0])) {
503
557
  const first = subProps[0];
504
558
  const subType = Array.isArray(first.Type) ? first.Type[0] : first.Type;
505
- options.push(parseUnionType(subType));
559
+ choices.push(parseUnionType(subType, options));
506
560
  continue;
507
561
  }
508
562
  if (subProps.every(isUnNamedProperty)) {
509
563
  const tupleItems = subProps.map((p) => {
510
564
  const subType = Array.isArray(p.Type) ? p.Type[0] : p.Type;
511
- return parseUnionType(subType);
565
+ return parseUnionType(subType, options);
512
566
  });
513
- options.push(b.tsTupleType(tupleItems));
567
+ choices.push(b.tsTupleType(tupleItems));
514
568
  continue;
515
569
  }
516
- options.push(b.tsTypeLiteral(parseObjectType(subProps)));
570
+ choices.push(b.tsTypeLiteral(parseObjectType(subProps, options)));
517
571
  }
518
- return b.tsUnionType(options);
572
+ return b.tsUnionType(choices);
519
573
  }
520
574
  if (prop.every(isUnNamedProperty)) {
521
575
  const items = prop.map(p => {
522
576
  const t = Array.isArray(p.Type) ? p.Type[0] : p.Type;
523
- return parseUnionType(t);
577
+ return parseUnionType(t, options);
524
578
  });
525
579
  if (items.length === 1)
526
580
  return items[0];
@@ -532,13 +586,13 @@ function parseUnionType(t) {
532
586
  if (prop.length === 1 && RECORD_KEY_TYPES.has(prop[0].Name)) {
533
587
  return b.tsTypeReference(b.identifier('Record'), b.tsTypeParameterInstantiation([
534
588
  NATIVE_TYPES[prop[0].Name],
535
- parseUnionType(prop[0].Type[0])
589
+ parseUnionType(prop[0].Type[0], options)
536
590
  ]));
537
591
  }
538
592
  /**
539
593
  * e.g. ?attributes: {*foo => text},
540
594
  */
541
- return b.tsTypeLiteral(parseObjectType(t.Properties));
595
+ return b.tsTypeLiteral(parseObjectType(t.Properties, options));
542
596
  }
543
597
  else if (isNamedGroupReference(t)) {
544
598
  return b.tsTypeReference(b.identifier(pascalCase(t.Value)));
@@ -385,9 +385,9 @@ export interface BrowsingContextPrintParameters {
385
385
  shrinkToFit?: boolean;
386
386
  }
387
387
 
388
- export // Minimum size is 1pt x 1pt. Conversion follows from
388
+ // Minimum size is 1pt x 1pt. Conversion follows from
389
389
  // https://www.w3.org/TR/css3-values/#absolute-lengths
390
- interface BrowsingContextPrintMarginParameters {
390
+ export interface BrowsingContextPrintMarginParameters {
391
391
  /**
392
392
  * @default 1
393
393
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cddl2ts",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "A Node.js package that can generate a TypeScript definition based on a CDDL file",
5
5
  "author": "Christian Bromann <mail@bromann.dev>",
6
6
  "license": "MIT",
@@ -32,7 +32,7 @@
32
32
  "camelcase": "^9.0.0",
33
33
  "recast": "^0.23.11",
34
34
  "yargs": "^18.0.0",
35
- "cddl": "0.19.1"
35
+ "cddl": "0.19.2"
36
36
  },
37
37
  "scripts": {
38
38
  "release": "release-it --config .release-it.ts --VV",
package/src/cli.ts CHANGED
@@ -4,9 +4,11 @@ import yargs from 'yargs'
4
4
 
5
5
  import { parse } from 'cddl'
6
6
 
7
- import { transform } from './index.js'
7
+ import { transform, type FieldCase } from './index.js'
8
8
  import { pkg } from './constants.js'
9
9
 
10
+ const FIELD_CASE_CHOICES = ['camel', 'snake'] as const satisfies readonly FieldCase[]
11
+
10
12
  export default async function cli (argv = process.argv.slice(2)) {
11
13
  const parser = yargs(argv)
12
14
  .usage(`${pkg.name}\n${pkg.description}\n\nUsage:\nrunme2ts ./path/to/spec.cddl &> ./path/to/interface.ts`)
@@ -18,6 +20,12 @@ export default async function cli (argv = process.argv.slice(2)) {
18
20
  description: 'Use unknown instead of any',
19
21
  default: false
20
22
  })
23
+ .option('field-case', {
24
+ choices: FIELD_CASE_CHOICES,
25
+ type: 'string',
26
+ description: 'Case for generated interface fields',
27
+ default: 'camel'
28
+ })
21
29
  .help('help')
22
30
  .alias('h', 'help')
23
31
  .alias('v', 'version')
@@ -38,5 +46,8 @@ export default async function cli (argv = process.argv.slice(2)) {
38
46
  }
39
47
 
40
48
  const ast = parse(absoluteFilePath)
41
- console.log(transform(ast, { useUnknown: args.u as boolean }))
49
+ console.log(transform(ast, {
50
+ useUnknown: args.u as boolean,
51
+ fieldCase: args.fieldCase as FieldCase
52
+ }))
42
53
  }
package/src/index.ts CHANGED
@@ -59,13 +59,23 @@ const RECORD_KEY_TYPES = new Set([
59
59
  type ObjectEntry = types.namedTypes.TSCallSignatureDeclaration | types.namedTypes.TSConstructSignatureDeclaration | types.namedTypes.TSIndexSignature | types.namedTypes.TSMethodSignature | types.namedTypes.TSPropertySignature
60
60
  type ObjectBody = ObjectEntry[]
61
61
  type TSTypeKind = types.namedTypes.TSAsExpression['typeAnnotation']
62
+ export type FieldCase = 'camel' | 'snake'
63
+ type TransformSettings = Required<TransformOptions>
64
+ const IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/
62
65
 
63
66
  export interface TransformOptions {
64
67
  useUnknown?: boolean
68
+ fieldCase?: FieldCase
65
69
  }
66
70
 
67
71
  export function transform (assignments: Assignment[], options?: TransformOptions) {
68
- if (options?.useUnknown) {
72
+ const transformOptions: TransformSettings = {
73
+ useUnknown: false,
74
+ fieldCase: 'camel',
75
+ ...options
76
+ }
77
+
78
+ if (transformOptions.useUnknown) {
69
79
  NATIVE_TYPES.any = b.tsUnknownKeyword()
70
80
  } else {
71
81
  NATIVE_TYPES.any = b.tsAnyKeyword()
@@ -81,7 +91,7 @@ export function transform (assignments: Assignment[], options?: TransformOptions
81
91
  ) satisfies types.namedTypes.File
82
92
 
83
93
  for (const assignment of assignments) {
84
- const statement = parseAssignment(assignment)
94
+ const statement = parseAssignment(assignment, transformOptions)
85
95
  if (!statement) {
86
96
  continue
87
97
  }
@@ -90,7 +100,51 @@ export function transform (assignments: Assignment[], options?: TransformOptions
90
100
  return print(ast).code
91
101
  }
92
102
 
93
- function parseAssignment (assignment: Assignment) {
103
+ function getAssignmentComments (assignment: Assignment) {
104
+ return assignment.Comments.map((c) => b.commentLine(` ${c.Content}`, true))
105
+ }
106
+
107
+ function exportWithComments (
108
+ declaration: types.namedTypes.TSTypeAliasDeclaration
109
+ | types.namedTypes.TSInterfaceDeclaration
110
+ ) {
111
+ const expr = b.exportDeclaration(false, declaration)
112
+ expr.comments = declaration.comments
113
+ declaration.comments = []
114
+ return expr
115
+ }
116
+
117
+ function isExtensibleRecordProperty (prop: Property) {
118
+ return !isUnNamedProperty(prop) &&
119
+ prop.Occurrence.m === Infinity &&
120
+ !prop.HasCut &&
121
+ RECORD_KEY_TYPES.has(prop.Name)
122
+ }
123
+
124
+ function toSnakeCase (name: string) {
125
+ return name
126
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
127
+ .replace(/([A-Z])([A-Z][a-z])/g, '$1_$2')
128
+ .replace(/[^A-Za-z0-9_$]+/g, '_')
129
+ .replace(/_+/g, '_')
130
+ .replace(/^_+|_+$/g, '')
131
+ .toLowerCase()
132
+ }
133
+
134
+ function formatFieldName (name: string, fieldCase: FieldCase) {
135
+ return fieldCase === 'snake'
136
+ ? toSnakeCase(name)
137
+ : camelcase(name)
138
+ }
139
+
140
+ function createPropertyKey (name: string, options: TransformSettings) {
141
+ const fieldName = formatFieldName(name, options.fieldCase)
142
+ return IDENTIFIER_PATTERN.test(fieldName)
143
+ ? b.identifier(fieldName)
144
+ : b.stringLiteral(fieldName)
145
+ }
146
+
147
+ function parseAssignment (assignment: Assignment, options: TransformSettings) {
94
148
  if (isVariable(assignment)) {
95
149
  const propType = Array.isArray(assignment.PropertyType)
96
150
  ? assignment.PropertyType
@@ -103,12 +157,12 @@ function parseAssignment (assignment: Assignment) {
103
157
  if (propType.length === 1 && propType[0].Type === 'range') {
104
158
  typeParameters = b.tsNumberKeyword()
105
159
  } else {
106
- typeParameters = b.tsUnionType(propType.map(parseUnionType))
160
+ typeParameters = b.tsUnionType(propType.map((prop) => parseUnionType(prop, options)))
107
161
  }
108
162
 
109
163
  const expr = b.tsTypeAliasDeclaration(id, typeParameters)
110
- expr.comments = assignment.Comments.map((c) => b.commentLine(` ${c.Content}`, true))
111
- return b.exportDeclaration(false, expr)
164
+ expr.comments = getAssignmentComments(assignment)
165
+ return exportWithComments(expr)
112
166
  }
113
167
 
114
168
  if (isGroup(assignment)) {
@@ -140,7 +194,7 @@ function parseAssignment (assignment: Assignment) {
140
194
  i++ // Skip next property
141
195
  }
142
196
 
143
- const options = choiceOptions.map(p => {
197
+ const choiceTypes = choiceOptions.map(p => {
144
198
  // If p is a group reference (Name ''), it's a TypeReference
145
199
  // e.g. SessionAutodetectProxyConfiguration // SessionDirectProxyConfiguration
146
200
  // The parser sometimes wraps it in an array, sometimes not (if inside a choice)
@@ -154,12 +208,12 @@ function parseAssignment (assignment: Assignment) {
154
208
  b.identifier(pascalCase(typeVal.Value || typeVal.Type))
155
209
  )
156
210
  }
157
- return parseUnionType(typeVal);
211
+ return parseUnionType(typeVal, options)
158
212
  }
159
213
  // Otherwise it is an object literal with this property
160
- return b.tsTypeLiteral(parseObjectType([p]))
214
+ return b.tsTypeLiteral(parseObjectType([p], options))
161
215
  })
162
- intersections.push(b.tsUnionType(options))
216
+ intersections.push(b.tsUnionType(choiceTypes))
163
217
  } else {
164
218
  staticProps.push(prop)
165
219
  }
@@ -171,13 +225,13 @@ function parseAssignment (assignment: Assignment) {
171
225
  const ownProps = staticProps.filter(p => !isUnNamedProperty(p))
172
226
 
173
227
  if (ownProps.length > 0) {
174
- intersections.unshift(b.tsTypeLiteral(parseObjectType(ownProps)))
228
+ intersections.unshift(b.tsTypeLiteral(parseObjectType(ownProps, options)))
175
229
  }
176
230
 
177
231
  for (const mixin of mixins) {
178
232
  if (Array.isArray(mixin.Type) && mixin.Type.length > 1) {
179
- const options = mixin.Type.map(parseUnionType)
180
- intersections.push(b.tsUnionType(options))
233
+ const choices = mixin.Type.map((type) => parseUnionType(type, options))
234
+ intersections.push(b.tsUnionType(choices))
181
235
  } else {
182
236
  const typeVal = Array.isArray(mixin.Type) ? mixin.Type[0] : mixin.Type
183
237
  if (isNamedGroupReference(typeVal)) {
@@ -201,8 +255,8 @@ function parseAssignment (assignment: Assignment) {
201
255
  }
202
256
 
203
257
  const expr = b.tsTypeAliasDeclaration(id, value)
204
- expr.comments = assignment.Comments.map((c) => b.commentLine(` ${c.Content}`, true))
205
- return b.exportDeclaration(false, expr)
258
+ expr.comments = getAssignmentComments(assignment)
259
+ return exportWithComments(expr)
206
260
  }
207
261
 
208
262
  const props = properties as Property[]
@@ -214,10 +268,10 @@ function parseAssignment (assignment: Assignment) {
214
268
  const prop = props[0]
215
269
  const propType = Array.isArray(prop.Type) ? prop.Type : [prop.Type]
216
270
  if (propType.length === 1 && RECORD_KEY_TYPES.has(prop.Name)) {
217
- const value = parseUnionType(assignment)
271
+ const value = parseUnionType(assignment, options)
218
272
  const expr = b.tsTypeAliasDeclaration(id, value)
219
- expr.comments = assignment.Comments.map((c) => b.commentLine(` ${c.Content}`, true))
220
- return b.exportDeclaration(false, expr)
273
+ expr.comments = getAssignmentComments(assignment)
274
+ return exportWithComments(expr)
221
275
  }
222
276
  }
223
277
 
@@ -263,10 +317,10 @@ function parseAssignment (assignment: Assignment) {
263
317
 
264
318
  for (const prop of group.Properties) {
265
319
  // Choices are wrapped in arrays in the properties
266
- const options = Array.isArray(prop) ? prop : [prop]
267
- if (options.length > 1) { // It's a choice within the mixin group
320
+ const choiceProps = Array.isArray(prop) ? prop : [prop]
321
+ if (choiceProps.length > 1) { // It's a choice within the mixin group
268
322
  const unionOptions: any[] = []
269
- for (const option of options) {
323
+ for (const option of choiceProps) {
270
324
  let refName: string | undefined
271
325
  const type = option.Type
272
326
  if (typeof type === 'string') refName = type
@@ -293,7 +347,7 @@ function parseAssignment (assignment: Assignment) {
293
347
  }
294
348
  }
295
349
 
296
- for (const option of options) {
350
+ for (const option of choiceProps) {
297
351
  let refName: string | undefined
298
352
  const type = option.Type
299
353
 
@@ -374,7 +428,7 @@ function parseAssignment (assignment: Assignment) {
374
428
  }
375
429
  } else if (type && typeof type === 'object') {
376
430
  if (isGroup(type) && Array.isArray(type.Properties)) {
377
- choices.push(b.tsTypeLiteral(parseObjectType(type.Properties as Property[])))
431
+ choices.push(b.tsTypeLiteral(parseObjectType(type.Properties as Property[], options)))
378
432
  continue
379
433
  }
380
434
  refName = isNamedGroupReference(type)
@@ -420,7 +474,7 @@ function parseAssignment (assignment: Assignment) {
420
474
 
421
475
  const ownProps = props.filter(p => !isUnNamedProperty(p))
422
476
  if (ownProps.length > 0) {
423
- intersections.push(b.tsTypeLiteral(parseObjectType(ownProps)))
477
+ intersections.push(b.tsTypeLiteral(parseObjectType(ownProps, options)))
424
478
  }
425
479
 
426
480
  let value: any
@@ -431,16 +485,16 @@ function parseAssignment (assignment: Assignment) {
431
485
  }
432
486
 
433
487
  const expr = b.tsTypeAliasDeclaration(id, value)
434
- expr.comments = assignment.Comments.map((c) => b.commentLine(` ${c.Content}`, true))
435
- return b.exportDeclaration(false, expr)
488
+ expr.comments = getAssignmentComments(assignment)
489
+ return exportWithComments(expr)
436
490
  }
437
491
 
438
492
  // Fallback to interface if no mixins (pure object)
439
- const objectType = parseObjectType(props)
493
+ const objectType = parseObjectType(props, options)
440
494
 
441
495
  const expr = b.tsInterfaceDeclaration(id, b.tsInterfaceBody(objectType))
442
- expr.comments = assignment.Comments.map((c) => b.commentLine(` ${c.Content}`, true))
443
- return b.exportDeclaration(false, expr)
496
+ expr.comments = getAssignmentComments(assignment)
497
+ return exportWithComments(expr)
444
498
  }
445
499
 
446
500
  if (isCDDLArray(assignment)) {
@@ -455,21 +509,21 @@ function parseAssignment (assignment: Assignment) {
455
509
  // We need to parse each choice.
456
510
  const obj = assignmentValues.map((prop) => {
457
511
  const t = Array.isArray(prop.Type) ? prop.Type[0] : prop.Type
458
- return parseUnionType(t)
512
+ return parseUnionType(t, options)
459
513
  })
460
514
  const value = b.tsArrayType(b.tsParenthesizedType(b.tsUnionType(obj)))
461
515
  const expr = b.tsTypeAliasDeclaration(id, value)
462
- expr.comments = assignment.Comments.map((c) => b.commentLine(` ${c.Content}`, true))
463
- return b.exportDeclaration(false, expr)
516
+ expr.comments = getAssignmentComments(assignment)
517
+ return exportWithComments(expr)
464
518
  }
465
519
 
466
520
  // Standard array
467
521
  const firstType = assignmentValues.Type
468
522
  const obj = Array.isArray(firstType)
469
- ? firstType.map(parseUnionType)
523
+ ? firstType.map((type) => parseUnionType(type, options))
470
524
  : isCDDLArray(firstType)
471
- ? firstType.Values.map((val: any) => parseUnionType(Array.isArray(val.Type) ? val.Type[0] : val.Type))
472
- : [parseUnionType(firstType)]
525
+ ? firstType.Values.map((val: any) => parseUnionType(Array.isArray(val.Type) ? val.Type[0] : val.Type, options))
526
+ : [parseUnionType(firstType, options)]
473
527
 
474
528
  const value = b.tsArrayType(
475
529
  obj.length === 1
@@ -477,14 +531,14 @@ function parseAssignment (assignment: Assignment) {
477
531
  : b.tsParenthesizedType(b.tsUnionType(obj))
478
532
  )
479
533
  const expr = b.tsTypeAliasDeclaration(id, value)
480
- expr.comments = assignment.Comments.map((c) => b.commentLine(` ${c.Content}`, true))
481
- return b.exportDeclaration(false, expr)
534
+ expr.comments = getAssignmentComments(assignment)
535
+ return exportWithComments(expr)
482
536
  }
483
537
 
484
538
  throw new Error(`Unknown assignment type "${(assignment as any).Type}"`)
485
539
  }
486
540
 
487
- function parseObjectType (props: Property[]): ObjectBody {
541
+ function parseObjectType (props: Property[], options: TransformSettings): ObjectBody {
488
542
  const propItems: ObjectBody = []
489
543
  for (const prop of props) {
490
544
  /**
@@ -502,10 +556,31 @@ function parseObjectType (props: Property[]): ObjectBody {
502
556
  continue
503
557
  }
504
558
 
505
- const id = b.identifier(camelcase(prop.Name))
506
559
  const cddlType: PropertyType[] = Array.isArray(prop.Type) ? prop.Type : [prop.Type]
507
560
  const comments: string[] = prop.Comments.map((c) => ` ${c.Content}`)
508
561
 
562
+ if (isExtensibleRecordProperty(prop)) {
563
+ const keyIdentifier = b.identifier('key')
564
+ keyIdentifier.typeAnnotation = b.tsTypeAnnotation(NATIVE_TYPES[prop.Name])
565
+
566
+ const indexSignature = b.tsIndexSignature(
567
+ [keyIdentifier],
568
+ b.tsTypeAnnotation(
569
+ b.tsUnionType([
570
+ ...cddlType.map((t) => parseUnionType(t, options)),
571
+ b.tsUndefinedKeyword()
572
+ ])
573
+ )
574
+ )
575
+ indexSignature.comments = comments.length
576
+ ? [b.commentBlock(`*\n *${comments.join('\n *')}\n `)]
577
+ : []
578
+ propItems.push(indexSignature)
579
+ continue
580
+ }
581
+
582
+ const id = createPropertyKey(prop.Name, options)
583
+
509
584
  if (prop.Operator && prop.Operator.Type === 'default') {
510
585
  const defaultValue = parseDefaultValue(prop.Operator)
511
586
  defaultValue && comments.length && comments.push('') // add empty line if we have previous comments
@@ -513,7 +588,7 @@ function parseObjectType (props: Property[]): ObjectBody {
513
588
  }
514
589
 
515
590
  const type = cddlType.map((t) => {
516
- const unionType = parseUnionType(t)
591
+ const unionType = parseUnionType(t, options)
517
592
  if (unionType) {
518
593
  const defaultValue = parseDefaultValue((t as PropertyReference).Operator)
519
594
  defaultValue && comments.length && comments.push('') // add empty line if we have previous comments
@@ -535,7 +610,7 @@ function parseObjectType (props: Property[]): ObjectBody {
535
610
  return propItems
536
611
  }
537
612
 
538
- function parseUnionType (t: PropertyType | Assignment): TSTypeKind {
613
+ function parseUnionType (t: PropertyType | Assignment, options: TransformSettings): TSTypeKind {
539
614
  if (typeof t === 'string') {
540
615
  if (!NATIVE_TYPES[t]) {
541
616
  throw new Error(`Unknown native type: "${t}`)
@@ -558,35 +633,35 @@ function parseUnionType (t: PropertyType | Assignment): TSTypeKind {
558
633
  * Check if we have choices in the group (arrays of Properties)
559
634
  */
560
635
  if (prop.some(p => Array.isArray(p))) {
561
- const options: TSTypeKind[] = []
636
+ const choices: TSTypeKind[] = []
562
637
  for (const choice of prop) {
563
638
  const subProps = Array.isArray(choice) ? choice : [choice]
564
639
 
565
640
  if (subProps.length === 1 && isUnNamedProperty(subProps[0])) {
566
641
  const first = subProps[0]
567
642
  const subType = Array.isArray(first.Type) ? first.Type[0] : first.Type
568
- options.push(parseUnionType(subType as PropertyType))
643
+ choices.push(parseUnionType(subType as PropertyType, options))
569
644
  continue
570
645
  }
571
646
 
572
647
  if (subProps.every(isUnNamedProperty)) {
573
648
  const tupleItems = subProps.map((p) => {
574
649
  const subType = Array.isArray(p.Type) ? p.Type[0] : p.Type
575
- return parseUnionType(subType as PropertyType)
650
+ return parseUnionType(subType as PropertyType, options)
576
651
  })
577
- options.push(b.tsTupleType(tupleItems))
652
+ choices.push(b.tsTupleType(tupleItems))
578
653
  continue
579
654
  }
580
655
 
581
- options.push(b.tsTypeLiteral(parseObjectType(subProps)))
656
+ choices.push(b.tsTypeLiteral(parseObjectType(subProps, options)))
582
657
  }
583
- return b.tsUnionType(options)
658
+ return b.tsUnionType(choices)
584
659
  }
585
660
 
586
661
  if ((prop as Property[]).every(isUnNamedProperty)) {
587
662
  const items = (prop as Property[]).map(p => {
588
663
  const t = Array.isArray(p.Type) ? p.Type[0] : p.Type
589
- return parseUnionType(t as PropertyType)
664
+ return parseUnionType(t as PropertyType, options)
590
665
  })
591
666
 
592
667
  if (items.length === 1) return items[0];
@@ -601,7 +676,7 @@ function parseUnionType (t: PropertyType | Assignment): TSTypeKind {
601
676
  b.identifier('Record'),
602
677
  b.tsTypeParameterInstantiation([
603
678
  NATIVE_TYPES[(prop[0] as Property).Name],
604
- parseUnionType(((prop[0] as Property).Type as PropertyType[])[0])
679
+ parseUnionType(((prop[0] as Property).Type as PropertyType[])[0], options)
605
680
  ])
606
681
  )
607
682
  }
@@ -609,7 +684,7 @@ function parseUnionType (t: PropertyType | Assignment): TSTypeKind {
609
684
  /**
610
685
  * e.g. ?attributes: {*foo => text},
611
686
  */
612
- return b.tsTypeLiteral(parseObjectType(t.Properties as Property[]))
687
+ return b.tsTypeLiteral(parseObjectType(t.Properties as Property[], options))
613
688
  } else if (isNamedGroupReference(t)) {
614
689
  return b.tsTypeReference(
615
690
  b.identifier(pascalCase(t.Value))
@@ -0,0 +1,16 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`extensible metadata > should render extensible metadata as an interface with an index signature 1`] = `
4
+ "export type MetadataScalar = null | boolean | number | number | string;
5
+
6
+ export interface MessageMetadata {
7
+ provider?: string;
8
+ model?: string;
9
+ modelType?: string;
10
+ runId?: string;
11
+ threadId?: string;
12
+ systemFingerprint?: string;
13
+ serviceTier?: string;
14
+ [key: string]: MetadataScalar | undefined;
15
+ }"
16
+ `;
@@ -12,52 +12,52 @@ export type DirectProxyConfiguration = Extensible & {
12
12
  proxyType: "direct";
13
13
  };
14
14
 
15
- export // 1. Simple Group Choice
16
- type ManualProxyConfiguration = Extensible & {
15
+ // 1. Simple Group Choice
16
+ export type ManualProxyConfiguration = Extensible & {
17
17
  proxyType: "manual";
18
18
  };
19
19
 
20
- export // 2. Nested Group Choice
21
- type SimpleGroupChoice = Int | string;
20
+ // 2. Nested Group Choice
21
+ export type SimpleGroupChoice = Int | string;
22
22
 
23
- export // 3. Group Choice with Multiple Items (Sequence) - interpreted as tuple
24
- type NestedGroupChoice = (Int | Tstr);
23
+ // 3. Group Choice with Multiple Items (Sequence) - interpreted as tuple
24
+ export type NestedGroupChoice = (Int | Tstr);
25
25
 
26
- export // 4. Map Group Choice - interpreted as map (interface) union
27
- type SequenceGroupChoice = [number, string] | number;
26
+ // 4. Map Group Choice - interpreted as map (interface) union
27
+ export type SequenceGroupChoice = [number, string] | number;
28
28
 
29
- export // 5. Type Choice inside Group - should be value union
30
- type MapGroupChoice = {
29
+ // 5. Type Choice inside Group - should be value union
30
+ export type MapGroupChoice = {
31
31
  a: 1;
32
32
  } | {
33
33
  b: 2;
34
34
  };
35
35
 
36
- export // 6. Array with Group Choice - should be array of union?
37
- type TypeChoiceInsideGroup = number | string;
36
+ // 6. Array with Group Choice - should be array of union?
37
+ export type TypeChoiceInsideGroup = number | string;
38
38
 
39
- export // 7. Map with nested group (bare and parens) - should be object like
40
- type ArrayGroupChoice = Int | string[];
39
+ // 7. Map with nested group (bare and parens) - should be object like
40
+ export type ArrayGroupChoice = Int | string[];
41
41
 
42
42
  export type MapWithBareGroup = number;
43
43
 
44
- export // 8. Group wrapped in map with multiple properties
45
- type MapWithParensGroup = number;
44
+ // 8. Group wrapped in map with multiple properties
45
+ export type MapWithParensGroup = number;
46
46
 
47
- export // 9. Property type choice without operators
48
- type MapGroupWrap = ;
47
+ // 9. Property type choice without operators
48
+ export type MapGroupWrap = ;
49
49
 
50
- export // 10. Nested choice with group reference
51
- interface PropChoiceNoOp {
50
+ // 10. Nested choice with group reference
51
+ export interface PropChoiceNoOp {
52
52
  a: number | number;
53
53
  b: string;
54
54
  }
55
55
 
56
- export // 11. Complex type (group) followed by property without comma
57
- type NestedChoiceRef = number | string;
56
+ // 11. Complex type (group) followed by property without comma
57
+ export type NestedChoiceRef = number | string;
58
58
 
59
- export // 12. Multi-item group choice (verifies isChoice persistence)
60
- interface MapNoComma {
59
+ // 12. Multi-item group choice (verifies isChoice persistence)
60
+ export interface MapNoComma {
61
61
  a: number;
62
62
  b: string;
63
63
  }
@@ -95,8 +95,8 @@ export interface SomeGroup {
95
95
  export type ScriptListLocalValue = ScriptLocalValue[];
96
96
  export type ScriptMappingLocalValue = (ScriptLocalValue | ScriptLocalValue)[];
97
97
 
98
- export // some comments here
99
- type Extensible = Record<string, any>;",
98
+ // some comments here
99
+ export type Extensible = Record<string, any>;",
100
100
  ],
101
101
  ]
102
102
  `;
@@ -388,9 +388,9 @@ export interface BrowsingContextPrintParameters {
388
388
  shrinkToFit?: boolean;
389
389
  }
390
390
 
391
- export // Minimum size is 1pt x 1pt. Conversion follows from
391
+ // Minimum size is 1pt x 1pt. Conversion follows from
392
392
  // https://www.w3.org/TR/css3-values/#absolute-lengths
393
- interface BrowsingContextPrintMarginParameters {
393
+ export interface BrowsingContextPrintMarginParameters {
394
394
  /**
395
395
  * @default 1
396
396
  */
@@ -0,0 +1,49 @@
1
+ import url from 'node:url'
2
+ import path from 'node:path'
3
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
4
+
5
+ import cli from '../src/cli.js'
6
+
7
+ const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
8
+ const cddlFile = path.join(__dirname, '..', '..', '..', 'examples', 'commons', 'extensible_metadata.cddl')
9
+
10
+ vi.mock('../src/constants', () => ({
11
+ pkg: {
12
+ name: 'cddl2ts',
13
+ version: '0.0.0'
14
+ }
15
+ }))
16
+
17
+ describe('extensible metadata', () => {
18
+ let exitOrig = process.exit
19
+ let logOrig = console.log
20
+ let errorOrig = console.error
21
+
22
+ beforeEach(() => {
23
+ process.exit = vi.fn() as any
24
+ console.log = vi.fn()
25
+ console.error = vi.fn()
26
+ })
27
+
28
+ afterEach(() => {
29
+ process.exit = exitOrig
30
+ console.log = logOrig
31
+ console.error = errorOrig
32
+ })
33
+
34
+ it('should render extensible metadata as an interface with an index signature', async () => {
35
+ await cli([cddlFile])
36
+
37
+ expect(process.exit).not.toHaveBeenCalledWith(1)
38
+ expect(console.error).not.toHaveBeenCalled()
39
+
40
+ const output = vi.mocked(console.log).mock.calls.flat().join('\n')
41
+
42
+ expect(output).toContain('export interface MessageMetadata {')
43
+ expect(output).toContain('provider?: string;')
44
+ expect(output).toContain('modelType?: string;')
45
+ expect(output).toContain('[key: string]: MetadataScalar | undefined;')
46
+ expect(output).not.toContain('text?: MetadataScalar;')
47
+ expect(output).toMatchSnapshot()
48
+ })
49
+ })
package/tests/mod.test.ts CHANGED
@@ -66,6 +66,23 @@ describe('cddl2ts', () => {
66
66
  expect(process.exit).toHaveBeenCalledTimes(0)
67
67
  })
68
68
 
69
+ it('should allow configuring snake_case fields from the CLI', async () => {
70
+ await cli([
71
+ path.join(__dirname, '..', '..', '..', 'examples', 'commons', 'test.cddl'),
72
+ '--field-case',
73
+ 'snake'
74
+ ])
75
+
76
+ expect(console.log).toHaveBeenCalledTimes(1)
77
+ const output = vi.mocked(console.log).mock.calls[0]?.[0] as string
78
+ expect(output).toContain('export interface SessionCapabilitiesRequest {')
79
+ expect(output).toContain('always_match?: SessionCapabilityRequest;')
80
+ expect(output).toContain('first_match?: SessionCapabilityRequest[];')
81
+ expect(output).toContain('pointer_type?: InputPointerType;')
82
+ expect(output).not.toContain('alwaysMatch?: SessionCapabilityRequest;')
83
+ expect(process.exit).toHaveBeenCalledTimes(0)
84
+ })
85
+
69
86
  afterEach(() => {
70
87
  process.exit = exitOrig
71
88
  console.log = logOrig
@@ -39,8 +39,8 @@ describe('named group choice', () => {
39
39
 
40
40
  const output = vi.mocked(console.log).mock.calls.flat().join('\n')
41
41
 
42
- // The export keyword is separated from the type definition by comments
43
- expect(output).toMatch(/export\s+(\/\/.*\n)+\s*type Choice = OptionA \| OptionB/)
42
+ // Leading comments should render before the exported declaration.
43
+ expect(output).toMatch(/(\/\/.*\n)+export type Choice = OptionA \| OptionB/)
44
44
  expect(output).toContain('export interface OptionA {')
45
45
  expect(output).toContain('export interface OptionB {')
46
46
  })
@@ -14,11 +14,11 @@ import { transform } from '../src/index.js'
14
14
 
15
15
  const COMMENTS: Comment[] = []
16
16
 
17
- function comment (content: string): Comment {
17
+ function comment (content: string, leading = false): Comment {
18
18
  return {
19
19
  Type: 'comment',
20
20
  Content: content,
21
- Leading: false
21
+ Leading: leading
22
22
  }
23
23
  }
24
24
 
@@ -123,6 +123,35 @@ describe('transform edge cases', () => {
123
123
  expect(output).toContain('export type MaybeValue = unknown;')
124
124
  })
125
125
 
126
+ it('should keep camelCase fields by default', () => {
127
+ const output = transform([
128
+ group('session-capability-request', [
129
+ property('page_ranges', 'tstr')
130
+ ])
131
+ ])
132
+
133
+ expect(output).toContain('export interface SessionCapabilityRequest {')
134
+ expect(output).toContain('pageRanges: string;')
135
+ })
136
+
137
+ it('should support snake_case fields without changing exported type names', () => {
138
+ const output = transform([
139
+ group('session-capability-request', [
140
+ property('acceptInsecureCerts', 'bool'),
141
+ property('pageRanges', 'tstr'),
142
+ property('nestedConfig', group('', [
143
+ property('requestId', 'uint')
144
+ ]))
145
+ ])
146
+ ], { fieldCase: 'snake' })
147
+
148
+ expect(output).toContain('export interface SessionCapabilityRequest {')
149
+ expect(output).toContain('accept_insecure_certs: boolean;')
150
+ expect(output).toContain('page_ranges: string;')
151
+ expect(output).toContain('nested_config: {')
152
+ expect(output).toContain('request_id: number;')
153
+ })
154
+
126
155
  it('should generate intersections for choices with static props and mixins', () => {
127
156
  const output = transform([
128
157
  group('combined', [
@@ -240,6 +269,40 @@ describe('transform edge cases', () => {
240
269
  expect(output).toContain('enabled?: boolean')
241
270
  })
242
271
 
272
+ it('should place leading comments before exported declarations', () => {
273
+ const output = transform([
274
+ variable('metadata-scalar', ['null', 'bool', 'int', 'float', 'text'], [
275
+ comment('Flat scalar value used by concise metadata bags.', true)
276
+ ])
277
+ ])
278
+
279
+ expect(output).toContain(`// Flat scalar value used by concise metadata bags.\nexport type MetadataScalar = null | boolean | number | number | string;`)
280
+ expect(output).not.toContain('export // Flat scalar value used by concise metadata bags.')
281
+ })
282
+
283
+ it('should emit extensible object properties as index signatures', () => {
284
+ const output = transform([
285
+ variable('metadata-scalar', ['null', 'bool', 'int', 'float', 'text']),
286
+ group('message-metadata', [
287
+ property('provider', 'text', {
288
+ Occurrence: { n: 0, m: 1 }
289
+ }),
290
+ property('model', 'text', {
291
+ Occurrence: { n: 0, m: 1 }
292
+ }),
293
+ property('text', groupRef('metadata-scalar'), {
294
+ Occurrence: { n: 0, m: Infinity }
295
+ })
296
+ ])
297
+ ])
298
+
299
+ expect(output).toContain('export interface MessageMetadata {')
300
+ expect(output).toContain('provider?: string;')
301
+ expect(output).toContain('model?: string;')
302
+ expect(output).toContain('[key: string]: MetadataScalar | undefined;')
303
+ expect(output).not.toContain('text?: MetadataScalar;')
304
+ })
305
+
243
306
  it('should throw clear errors for unsupported inputs', () => {
244
307
  expect(() => transform([
245
308
  variable('unknown-native', 'nope')