Skip to content

Commit a04af47

Browse files
feat(cddl2ts): add configurable field casing (#47)
* feat(cddl2ts): add configurable field casing * fix(cddl2ts): resolve field casing compile error
1 parent afa27ae commit a04af47

6 files changed

Lines changed: 142 additions & 46 deletions

File tree

packages/cddl2ts/README.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ This package exposes a CLI as well as a programmatic interface for transforming
2323
npx cddl2ts ./path/to/interface.cddl &> ./path/to/interface.ts
2424
```
2525

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+
2632
### Programmatic Interface
2733

2834
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'
4147
* };
4248
*/
4349
const ast = parse('./spec.cddl')
44-
const ts = transform(ast)
50+
const ts = transform(ast, { fieldCase: 'snake' })
4551
console.log(ts)
4652
/**
4753
* outputs:
4854
*
4955
* 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,
5460
* }
5561
*/
5662
```

packages/cddl2ts/cli-examples/remote.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -385,9 +385,9 @@ export interface BrowsingContextPrintParameters {
385385
shrinkToFit?: boolean;
386386
}
387387

388-
export // Minimum size is 1pt x 1pt. Conversion follows from
388+
// Minimum size is 1pt x 1pt. Conversion follows from
389389
// https://www.w3.org/TR/css3-values/#absolute-lengths
390-
interface BrowsingContextPrintMarginParameters {
390+
export interface BrowsingContextPrintMarginParameters {
391391
/**
392392
* @default 1
393393
*/

packages/cddl2ts/src/cli.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import yargs from 'yargs'
44

55
import { parse } from 'cddl'
66

7-
import { transform } from './index.js'
7+
import { transform, type FieldCase } from './index.js'
88
import { pkg } from './constants.js'
99

10+
const FIELD_CASE_CHOICES = ['camel', 'snake'] as const satisfies readonly FieldCase[]
11+
1012
export default async function cli (argv = process.argv.slice(2)) {
1113
const parser = yargs(argv)
1214
.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)) {
1820
description: 'Use unknown instead of any',
1921
default: false
2022
})
23+
.option('field-case', {
24+
choices: FIELD_CASE_CHOICES,
25+
type: 'string',
26+
description: 'Case for generated interface fields',
27+
default: 'camel'
28+
})
2129
.help('help')
2230
.alias('h', 'help')
2331
.alias('v', 'version')
@@ -38,5 +46,8 @@ export default async function cli (argv = process.argv.slice(2)) {
3846
}
3947

4048
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+
}))
4253
}

packages/cddl2ts/src/index.ts

Lines changed: 70 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,23 @@ const RECORD_KEY_TYPES = new Set([
5959
type ObjectEntry = types.namedTypes.TSCallSignatureDeclaration | types.namedTypes.TSConstructSignatureDeclaration | types.namedTypes.TSIndexSignature | types.namedTypes.TSMethodSignature | types.namedTypes.TSPropertySignature
6060
type ObjectBody = ObjectEntry[]
6161
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_$]*$/
6265

6366
export interface TransformOptions {
6467
useUnknown?: boolean
68+
fieldCase?: FieldCase
6569
}
6670

6771
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) {
6979
NATIVE_TYPES.any = b.tsUnknownKeyword()
7080
} else {
7181
NATIVE_TYPES.any = b.tsAnyKeyword()
@@ -81,7 +91,7 @@ export function transform (assignments: Assignment[], options?: TransformOptions
8191
) satisfies types.namedTypes.File
8292

8393
for (const assignment of assignments) {
84-
const statement = parseAssignment(assignment)
94+
const statement = parseAssignment(assignment, transformOptions)
8595
if (!statement) {
8696
continue
8797
}
@@ -111,7 +121,30 @@ function isExtensibleRecordProperty (prop: Property) {
111121
RECORD_KEY_TYPES.has(prop.Name)
112122
}
113123

114-
function parseAssignment (assignment: Assignment) {
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) {
115148
if (isVariable(assignment)) {
116149
const propType = Array.isArray(assignment.PropertyType)
117150
? assignment.PropertyType
@@ -124,7 +157,7 @@ function parseAssignment (assignment: Assignment) {
124157
if (propType.length === 1 && propType[0].Type === 'range') {
125158
typeParameters = b.tsNumberKeyword()
126159
} else {
127-
typeParameters = b.tsUnionType(propType.map(parseUnionType))
160+
typeParameters = b.tsUnionType(propType.map((prop) => parseUnionType(prop, options)))
128161
}
129162

130163
const expr = b.tsTypeAliasDeclaration(id, typeParameters)
@@ -161,7 +194,7 @@ function parseAssignment (assignment: Assignment) {
161194
i++ // Skip next property
162195
}
163196

164-
const options = choiceOptions.map(p => {
197+
const choiceTypes = choiceOptions.map(p => {
165198
// If p is a group reference (Name ''), it's a TypeReference
166199
// e.g. SessionAutodetectProxyConfiguration // SessionDirectProxyConfiguration
167200
// The parser sometimes wraps it in an array, sometimes not (if inside a choice)
@@ -175,12 +208,12 @@ function parseAssignment (assignment: Assignment) {
175208
b.identifier(pascalCase(typeVal.Value || typeVal.Type))
176209
)
177210
}
178-
return parseUnionType(typeVal);
211+
return parseUnionType(typeVal, options)
179212
}
180213
// Otherwise it is an object literal with this property
181-
return b.tsTypeLiteral(parseObjectType([p]))
214+
return b.tsTypeLiteral(parseObjectType([p], options))
182215
})
183-
intersections.push(b.tsUnionType(options))
216+
intersections.push(b.tsUnionType(choiceTypes))
184217
} else {
185218
staticProps.push(prop)
186219
}
@@ -192,13 +225,13 @@ function parseAssignment (assignment: Assignment) {
192225
const ownProps = staticProps.filter(p => !isUnNamedProperty(p))
193226

194227
if (ownProps.length > 0) {
195-
intersections.unshift(b.tsTypeLiteral(parseObjectType(ownProps)))
228+
intersections.unshift(b.tsTypeLiteral(parseObjectType(ownProps, options)))
196229
}
197230

198231
for (const mixin of mixins) {
199232
if (Array.isArray(mixin.Type) && mixin.Type.length > 1) {
200-
const options = mixin.Type.map(parseUnionType)
201-
intersections.push(b.tsUnionType(options))
233+
const choices = mixin.Type.map((type) => parseUnionType(type, options))
234+
intersections.push(b.tsUnionType(choices))
202235
} else {
203236
const typeVal = Array.isArray(mixin.Type) ? mixin.Type[0] : mixin.Type
204237
if (isNamedGroupReference(typeVal)) {
@@ -235,7 +268,7 @@ function parseAssignment (assignment: Assignment) {
235268
const prop = props[0]
236269
const propType = Array.isArray(prop.Type) ? prop.Type : [prop.Type]
237270
if (propType.length === 1 && RECORD_KEY_TYPES.has(prop.Name)) {
238-
const value = parseUnionType(assignment)
271+
const value = parseUnionType(assignment, options)
239272
const expr = b.tsTypeAliasDeclaration(id, value)
240273
expr.comments = getAssignmentComments(assignment)
241274
return exportWithComments(expr)
@@ -284,10 +317,10 @@ function parseAssignment (assignment: Assignment) {
284317

285318
for (const prop of group.Properties) {
286319
// Choices are wrapped in arrays in the properties
287-
const options = Array.isArray(prop) ? prop : [prop]
288-
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
289322
const unionOptions: any[] = []
290-
for (const option of options) {
323+
for (const option of choiceProps) {
291324
let refName: string | undefined
292325
const type = option.Type
293326
if (typeof type === 'string') refName = type
@@ -314,7 +347,7 @@ function parseAssignment (assignment: Assignment) {
314347
}
315348
}
316349

317-
for (const option of options) {
350+
for (const option of choiceProps) {
318351
let refName: string | undefined
319352
const type = option.Type
320353

@@ -395,7 +428,7 @@ function parseAssignment (assignment: Assignment) {
395428
}
396429
} else if (type && typeof type === 'object') {
397430
if (isGroup(type) && Array.isArray(type.Properties)) {
398-
choices.push(b.tsTypeLiteral(parseObjectType(type.Properties as Property[])))
431+
choices.push(b.tsTypeLiteral(parseObjectType(type.Properties as Property[], options)))
399432
continue
400433
}
401434
refName = isNamedGroupReference(type)
@@ -441,7 +474,7 @@ function parseAssignment (assignment: Assignment) {
441474

442475
const ownProps = props.filter(p => !isUnNamedProperty(p))
443476
if (ownProps.length > 0) {
444-
intersections.push(b.tsTypeLiteral(parseObjectType(ownProps)))
477+
intersections.push(b.tsTypeLiteral(parseObjectType(ownProps, options)))
445478
}
446479

447480
let value: any
@@ -457,7 +490,7 @@ function parseAssignment (assignment: Assignment) {
457490
}
458491

459492
// Fallback to interface if no mixins (pure object)
460-
const objectType = parseObjectType(props)
493+
const objectType = parseObjectType(props, options)
461494

462495
const expr = b.tsInterfaceDeclaration(id, b.tsInterfaceBody(objectType))
463496
expr.comments = getAssignmentComments(assignment)
@@ -476,7 +509,7 @@ function parseAssignment (assignment: Assignment) {
476509
// We need to parse each choice.
477510
const obj = assignmentValues.map((prop) => {
478511
const t = Array.isArray(prop.Type) ? prop.Type[0] : prop.Type
479-
return parseUnionType(t)
512+
return parseUnionType(t, options)
480513
})
481514
const value = b.tsArrayType(b.tsParenthesizedType(b.tsUnionType(obj)))
482515
const expr = b.tsTypeAliasDeclaration(id, value)
@@ -487,10 +520,10 @@ function parseAssignment (assignment: Assignment) {
487520
// Standard array
488521
const firstType = assignmentValues.Type
489522
const obj = Array.isArray(firstType)
490-
? firstType.map(parseUnionType)
523+
? firstType.map((type) => parseUnionType(type, options))
491524
: isCDDLArray(firstType)
492-
? firstType.Values.map((val: any) => parseUnionType(Array.isArray(val.Type) ? val.Type[0] : val.Type))
493-
: [parseUnionType(firstType)]
525+
? firstType.Values.map((val: any) => parseUnionType(Array.isArray(val.Type) ? val.Type[0] : val.Type, options))
526+
: [parseUnionType(firstType, options)]
494527

495528
const value = b.tsArrayType(
496529
obj.length === 1
@@ -505,7 +538,7 @@ function parseAssignment (assignment: Assignment) {
505538
throw new Error(`Unknown assignment type "${(assignment as any).Type}"`)
506539
}
507540

508-
function parseObjectType (props: Property[]): ObjectBody {
541+
function parseObjectType (props: Property[], options: TransformSettings): ObjectBody {
509542
const propItems: ObjectBody = []
510543
for (const prop of props) {
511544
/**
@@ -534,7 +567,7 @@ function parseObjectType (props: Property[]): ObjectBody {
534567
[keyIdentifier],
535568
b.tsTypeAnnotation(
536569
b.tsUnionType([
537-
...cddlType.map((t) => parseUnionType(t)),
570+
...cddlType.map((t) => parseUnionType(t, options)),
538571
b.tsUndefinedKeyword()
539572
])
540573
)
@@ -546,7 +579,7 @@ function parseObjectType (props: Property[]): ObjectBody {
546579
continue
547580
}
548581

549-
const id = b.identifier(camelcase(prop.Name))
582+
const id = createPropertyKey(prop.Name, options)
550583

551584
if (prop.Operator && prop.Operator.Type === 'default') {
552585
const defaultValue = parseDefaultValue(prop.Operator)
@@ -555,7 +588,7 @@ function parseObjectType (props: Property[]): ObjectBody {
555588
}
556589

557590
const type = cddlType.map((t) => {
558-
const unionType = parseUnionType(t)
591+
const unionType = parseUnionType(t, options)
559592
if (unionType) {
560593
const defaultValue = parseDefaultValue((t as PropertyReference).Operator)
561594
defaultValue && comments.length && comments.push('') // add empty line if we have previous comments
@@ -577,7 +610,7 @@ function parseObjectType (props: Property[]): ObjectBody {
577610
return propItems
578611
}
579612

580-
function parseUnionType (t: PropertyType | Assignment): TSTypeKind {
613+
function parseUnionType (t: PropertyType | Assignment, options: TransformSettings): TSTypeKind {
581614
if (typeof t === 'string') {
582615
if (!NATIVE_TYPES[t]) {
583616
throw new Error(`Unknown native type: "${t}`)
@@ -600,35 +633,35 @@ function parseUnionType (t: PropertyType | Assignment): TSTypeKind {
600633
* Check if we have choices in the group (arrays of Properties)
601634
*/
602635
if (prop.some(p => Array.isArray(p))) {
603-
const options: TSTypeKind[] = []
636+
const choices: TSTypeKind[] = []
604637
for (const choice of prop) {
605638
const subProps = Array.isArray(choice) ? choice : [choice]
606639

607640
if (subProps.length === 1 && isUnNamedProperty(subProps[0])) {
608641
const first = subProps[0]
609642
const subType = Array.isArray(first.Type) ? first.Type[0] : first.Type
610-
options.push(parseUnionType(subType as PropertyType))
643+
choices.push(parseUnionType(subType as PropertyType, options))
611644
continue
612645
}
613646

614647
if (subProps.every(isUnNamedProperty)) {
615648
const tupleItems = subProps.map((p) => {
616649
const subType = Array.isArray(p.Type) ? p.Type[0] : p.Type
617-
return parseUnionType(subType as PropertyType)
650+
return parseUnionType(subType as PropertyType, options)
618651
})
619-
options.push(b.tsTupleType(tupleItems))
652+
choices.push(b.tsTupleType(tupleItems))
620653
continue
621654
}
622655

623-
options.push(b.tsTypeLiteral(parseObjectType(subProps)))
656+
choices.push(b.tsTypeLiteral(parseObjectType(subProps, options)))
624657
}
625-
return b.tsUnionType(options)
658+
return b.tsUnionType(choices)
626659
}
627660

628661
if ((prop as Property[]).every(isUnNamedProperty)) {
629662
const items = (prop as Property[]).map(p => {
630663
const t = Array.isArray(p.Type) ? p.Type[0] : p.Type
631-
return parseUnionType(t as PropertyType)
664+
return parseUnionType(t as PropertyType, options)
632665
})
633666

634667
if (items.length === 1) return items[0];
@@ -643,15 +676,15 @@ function parseUnionType (t: PropertyType | Assignment): TSTypeKind {
643676
b.identifier('Record'),
644677
b.tsTypeParameterInstantiation([
645678
NATIVE_TYPES[(prop[0] as Property).Name],
646-
parseUnionType(((prop[0] as Property).Type as PropertyType[])[0])
679+
parseUnionType(((prop[0] as Property).Type as PropertyType[])[0], options)
647680
])
648681
)
649682
}
650683

651684
/**
652685
* e.g. ?attributes: {*foo => text},
653686
*/
654-
return b.tsTypeLiteral(parseObjectType(t.Properties as Property[]))
687+
return b.tsTypeLiteral(parseObjectType(t.Properties as Property[], options))
655688
} else if (isNamedGroupReference(t)) {
656689
return b.tsTypeReference(
657690
b.identifier(pascalCase(t.Value))

0 commit comments

Comments
 (0)