diff --git a/packages/cddl/src/utils.ts b/packages/cddl/src/utils.ts index 6d36def..6e1a4c9 100644 --- a/packages/cddl/src/utils.ts +++ b/packages/cddl/src/utils.ts @@ -81,7 +81,32 @@ export function isPropertyReference (t: any): t is PropertyReference { } export function isNativeTypeWithOperator (t: any): t is NativeTypeWithOperator { - return t && typeof t.Type === 'object' && 'Operator' in t + return Boolean( + t && + typeof t === 'object' && + 'Type' in t && + !('Value' in t) && + t.Operator && + typeof t.Operator === 'object' + ) +} + +export function getRegexpPattern (t: any): string | undefined { + if (!isNativeTypeWithOperator(t)) { + return + } + + if (typeof t.Type !== 'string' || !['str', 'text', 'tstr'].includes(t.Type)) { + return + } + + if (t.Operator?.Type !== 'regexp' || !isLiteralWithValue(t.Operator.Value)) { + return + } + + return typeof t.Operator.Value.Value === 'string' + ? t.Operator.Value.Value + : undefined } export function isRange (t: any): boolean { diff --git a/packages/cddl/tests/cli.test.ts b/packages/cddl/tests/cli.test.ts new file mode 100644 index 0000000..ead72cd --- /dev/null +++ b/packages/cddl/tests/cli.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { CLI_EPILOGUE } from '../src/cli/constants.js' + +describe('cli entrypoint', () => { + afterEach(() => { + vi.resetModules() + vi.restoreAllMocks() + }) + + it('wires yargs commands and returns argv', async () => { + const command = vi.fn() + const example = vi.fn() + const epilogue = vi.fn() + const demandCommand = vi.fn() + const help = vi.fn() + const argvValue = { _: ['repl'] } + const chain = { + command, + example, + epilogue, + demandCommand, + help, + argv: argvValue + } + + command.mockReturnValue(chain) + example.mockReturnValue(chain) + epilogue.mockReturnValue(chain) + demandCommand.mockReturnValue(chain) + help.mockReturnValue(chain) + + const yargsMock = vi.fn().mockReturnValue(chain) + const hideBinMock = vi.fn().mockReturnValue(['repl']) + + vi.doMock('yargs/yargs', () => ({ default: yargsMock })) + vi.doMock('yargs/helpers', () => ({ hideBin: hideBinMock })) + + const { default: runCli } = await import('../src/cli/index.js') + const result = await runCli() + + expect(hideBinMock).toHaveBeenCalledWith(process.argv) + expect(yargsMock).toHaveBeenCalledWith(['repl']) + expect(command).toHaveBeenCalledTimes(2) + expect(example).toHaveBeenCalledWith('$0 repl', 'Start CDDL repl') + expect(epilogue).toHaveBeenCalledWith(CLI_EPILOGUE) + expect(demandCommand).toHaveBeenCalledTimes(1) + expect(help).toHaveBeenCalledTimes(1) + expect(result).toEqual(argvValue) + }) +}) diff --git a/packages/cddl/tests/lexer.test.ts b/packages/cddl/tests/lexer.test.ts index 7e332cb..6c41d09 100644 --- a/packages/cddl/tests/lexer.test.ts +++ b/packages/cddl/tests/lexer.test.ts @@ -60,4 +60,12 @@ describe('lexer', () => { expect(locEnd.line).toBe(0) expect(locEnd.position).toBe(0) }) + + it('should render caret location info for the current line', () => { + const l = new Lexer('foo\nbar') + l.nextToken() + + expect(l.getLine(1)).toBe('bar') + expect(l.getLocationInfo()).toBe('foo\n ^\n |\n') + }) }) diff --git a/packages/cddl/tests/parser.test.ts b/packages/cddl/tests/parser.test.ts index f6103ec..5cfe878 100644 --- a/packages/cddl/tests/parser.test.ts +++ b/packages/cddl/tests/parser.test.ts @@ -60,4 +60,29 @@ describe('parser', () => { vi.restoreAllMocks() }) + + it('parses RFC 9165 regexp operators on text strings', () => { + vi.spyOn(fs, 'readFileSync').mockReturnValue('channel = tstr .regexp "custom:.+"\n') + const p = new Parser('foo.cddl') + + expect(p.parse()).toEqual([{ + Type: 'variable', + Name: 'channel', + IsChoiceAddition: false, + PropertyType: [{ + Type: 'tstr', + Operator: { + Type: 'regexp', + Value: { + Type: 'literal', + Value: 'custom:.+', + Unwrapped: false + } + } + }], + Comments: [] + }]) + + vi.restoreAllMocks() + }) }) diff --git a/packages/cddl/tests/utils.test.ts b/packages/cddl/tests/utils.test.ts index a99279a..e499be5 100644 --- a/packages/cddl/tests/utils.test.ts +++ b/packages/cddl/tests/utils.test.ts @@ -10,8 +10,10 @@ import type { PropertyReference, Variable } from '../src/ast.js' +import { Type } from '../src/ast.js' import { Tokens, type Token } from '../src/tokens.js' import { + getRegexpPattern, hasSpecialNumberCharacter, isAlphabeticCharacter, isCDDLArray, @@ -183,6 +185,17 @@ describe('utils', () => { Value: 'tstr' } } + const nativeStringTypeWithRegexp: NativeTypeWithOperator = { + Type: Type.TSTR, + Operator: { + Type: 'regexp', + Value: { + Type: 'literal', + Value: 'custom:.+', + Unwrapped: false + } + } + } const rangeReference: PropertyReference = { Type: 'range', Value: { @@ -194,7 +207,25 @@ describe('utils', () => { } expect(isNativeTypeWithOperator(nativeTypeWithOperator)).toBe(true) + expect(isNativeTypeWithOperator(nativeStringTypeWithRegexp)).toBe(true) expect(isNativeTypeWithOperator({ Type: 'tstr' })).toBe(false) + expect(isNativeTypeWithOperator({ + Type: Type.TSTR, + Operator: 'regexp' + })).toBe(false) + expect(getRegexpPattern(nativeStringTypeWithRegexp)).toBe('custom:.+') + expect(getRegexpPattern(nativeTypeWithOperator)).toBeUndefined() + expect(getRegexpPattern({ + Type: Type.TSTR, + Operator: { + Type: 'regexp', + Value: { + Type: 'literal', + Value: 42, + Unwrapped: false + } + } + })).toBeUndefined() expect(isRange({ Type: rangeReference })).toBe(true) expect(isRange({ Type: 'range' })).toBe(false) diff --git a/packages/cddl2py/src/index.ts b/packages/cddl2py/src/index.ts index 9c903ed..ffa1aef 100644 --- a/packages/cddl2py/src/index.ts +++ b/packages/cddl2py/src/index.ts @@ -1,8 +1,9 @@ import { + getRegexpPattern, isCDDLArray, isGroup, isNamedGroupReference, isLiteralWithValue, isNativeTypeWithOperator, isUnNamedProperty, isPropertyReference, isRange, isVariable, pascalCase, - type Assignment, type PropertyType, type PropertyReference, + type Assignment, type NativeTypeWithOperator, type PropertyType, type PropertyReference, type Property, type Array as CDDLArray, type Operator, type Group, type Variable, type Comment, type Tag } from 'cddl' @@ -516,6 +517,72 @@ function getExtraItemsType (props: Property[], ctx: Context): string | undefined return `Union[${uniqueTypes.join(', ')}]` } +function stringifyPythonLiteral (value: string) { + return JSON.stringify(value) +} + +function getTemplateAnnotatedPattern (regexpPattern: string): string | undefined { + const wildcard = '.+' + if (!regexpPattern.includes(wildcard) || /[\\()[\]{}|?*^$]/.test(regexpPattern.replaceAll(wildcard, ''))) { + return + } + + const segments = regexpPattern.split(wildcard) + const parts: string[] = [] + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i] + if (segment.length > 0) { + parts.push(stringifyPythonLiteral(segment)) + } + + if (i < segments.length - 1) { + parts.push('str') + } + } + + if (parts.length === 0 || !parts.includes('str')) { + return + } + + return `Annotated[str, ${parts.join(' + ')}]` +} + +function resolveNativeTypeWithOperator (t: NativeTypeWithOperator, ctx: Context): string | undefined { + if (typeof t.Type !== 'string') { + return + } + + const mapped = NATIVE_TYPE_MAP[t.Type] + if (!mapped) { + return + } + + const regexpPattern = getRegexpPattern(t) + if (!regexpPattern) { + if (mapped === 'Any') { + ctx.typingImports.add('Any') + } + return mapped + } + + const templateAnnotatedPattern = getTemplateAnnotatedPattern(regexpPattern) + if (!templateAnnotatedPattern) { + if (mapped === 'Any') { + ctx.typingImports.add('Any') + } + return mapped + } + + ctx.typingImports.add('Annotated') + if (ctx.pydantic) { + ctx.pydanticImports.add('StringConstraints') + return `Annotated[${mapped}, StringConstraints(pattern=${JSON.stringify(regexpPattern)})]` + } + + return templateAnnotatedPattern +} + // --------------------------------------------------------------------------- // Type resolution // --------------------------------------------------------------------------- @@ -532,6 +599,14 @@ function resolveType (t: PropertyType, ctx: Context, options: ResolveTypeOptions throw new Error(`Unknown native type: "${t}"`) } + if (isNativeTypeWithOperator(t) && typeof t.Type === 'string') { + const resolved = resolveNativeTypeWithOperator(t, ctx) + if (resolved) { + return resolved + } + throw new Error(`Unknown native type with operator: ${JSON.stringify(t)}`) + } + if ((t as any).Type && typeof (t as any).Type === 'string' && NATIVE_TYPE_MAP[(t as any).Type]) { const mapped = NATIVE_TYPE_MAP[(t as any).Type] if (mapped === 'Any') { diff --git a/packages/cddl2py/tests/transform_edge_cases.test.ts b/packages/cddl2py/tests/transform_edge_cases.test.ts index 6dd4943..5b7e3de 100644 --- a/packages/cddl2py/tests/transform_edge_cases.test.ts +++ b/packages/cddl2py/tests/transform_edge_cases.test.ts @@ -151,6 +151,74 @@ describe('transform edge cases', () => { expect(output).toContain('Combined = Union[_CombinedVariant0, _CombinedVariant1]') }) + it('should preserve template-like regexp strings in python-friendly types', () => { + const typedDictOutput = transform([ + variable('channel', { + Type: 'tstr', + Operator: { + Type: 'regexp', + Value: literal('custom:.+') + } + } as any), + variable('prefixed-name', { + Type: 'tstr', + Operator: { + Type: 'regexp', + Value: literal('foo_.+') + } + } as any), + variable('sandwiched-name', { + Type: 'tstr', + Operator: { + Type: 'regexp', + Value: literal('some_.+_name') + } + } as any), + variable('multi-slot-name', { + Type: 'tstr', + Operator: { + Type: 'regexp', + Value: literal('pre_.+_mid_.+_post') + } + } as any), + group('event-envelope', [ + property('channel', { + Type: 'tstr', + Operator: { + Type: 'regexp', + Value: literal('custom:.+') + } + } as any) + ]), + variable('email-address', { + Type: 'tstr', + Operator: { + Type: 'regexp', + Value: literal('[^@]+@[^@]+') + } + } as any) + ]) + const pydanticOutput = transform([ + variable('channel', { + Type: 'tstr', + Operator: { + Type: 'regexp', + Value: literal('custom:.+') + } + } as any) + ], { pydantic: true }) + + expect(typedDictOutput).toContain('from typing import Annotated') + expect(typedDictOutput).toContain('Channel = Annotated[str, "custom:" + str]') + expect(typedDictOutput).toContain('PrefixedName = Annotated[str, "foo_" + str]') + expect(typedDictOutput).toContain('SandwichedName = Annotated[str, "some_" + str + "_name"]') + expect(typedDictOutput).toContain('MultiSlotName = Annotated[str, "pre_" + str + "_mid_" + str + "_post"]') + expect(typedDictOutput).toContain('channel: Annotated[str, "custom:" + str]') + expect(typedDictOutput).toContain('EmailAddress = str') + expect(pydanticOutput).toContain('from pydantic import StringConstraints') + expect(pydanticOutput).toContain('Channel = Annotated[str, StringConstraints(pattern="custom:.+")]') + }) + it('should collapse multiple union mixin groups into a single alias', () => { const output = transform([ group('combined', [ diff --git a/packages/cddl2ts/src/index.ts b/packages/cddl2ts/src/index.ts index 79c8f4b..c8bdbb9 100644 --- a/packages/cddl2ts/src/index.ts +++ b/packages/cddl2ts/src/index.ts @@ -3,6 +3,7 @@ import { parse, print, types } from 'recast' import typescriptParser from 'recast/parsers/typescript.js' import { + getRegexpPattern, isCDDLArray, isGroup, isNamedGroupReference, @@ -14,6 +15,7 @@ import { isVariable, pascalCase, type Assignment, + type NativeTypeWithOperator, type PropertyType, type PropertyReference, type Property, @@ -610,12 +612,70 @@ function parseObjectType (props: Property[], options: TransformSettings): Object return propItems } +function parseTemplateLiteralType (template: string): TSTypeKind { + const ast = parse(`type __CDDLTemplate = ${template};`, { + parser: typescriptParser, + sourceFileName: 'cddl2Ts.ts', + sourceRoot: process.cwd() + }) satisfies types.namedTypes.File + return (ast.program.body[0] as types.namedTypes.TSTypeAliasDeclaration).typeAnnotation +} + +function escapeTemplateLiteralSegment (segment: string): string { + return segment + .replace(/\\/g, '\\\\') + .replace(/`/g, '\\`') + .replace(/\$\{/g, '\\${') +} + +function regexpPatternToTemplateLiteral (pattern: string): string | undefined { + const normalized = pattern.startsWith('^') && pattern.endsWith('$') + ? pattern.slice(1, -1) + : pattern + + if (!normalized.includes('.+')) { + return + } + + const wildcardOnlyPattern = normalized.replace(/(\.\+)+/g, '') + if (wildcardOnlyPattern.includes('(') || wildcardOnlyPattern.includes('[') || wildcardOnlyPattern.includes('\\')) { + return + } + + const segments = normalized.split(/(?:\.\+)+/g) + if (segments.length <= 1) { + return + } + + return `\`${segments.map(escapeTemplateLiteralSegment).join('${string}')}\`` +} + +function parseNativeTypeWithOperator (t: NativeTypeWithOperator): TSTypeKind | undefined { + if (typeof t.Type !== 'string') { + return + } + + const regexpPattern = getRegexpPattern(t) + const templateLiteral = regexpPattern && regexpPatternToTemplateLiteral(regexpPattern) + if (templateLiteral) { + return parseTemplateLiteralType(templateLiteral) + } + + return NATIVE_TYPES[t.Type] +} + function parseUnionType (t: PropertyType | Assignment, options: TransformSettings): TSTypeKind { if (typeof t === 'string') { if (!NATIVE_TYPES[t]) { throw new Error(`Unknown native type: "${t}`) } return NATIVE_TYPES[t] + } else if (isNativeTypeWithOperator(t) && typeof t.Type === 'string') { + const nativeType = parseNativeTypeWithOperator(t) + if (!nativeType) { + throw new Error(`Unknown native type with operator: ${JSON.stringify(t)}`) + } + return nativeType } else if ((t as any).Type && typeof (t as any).Type === 'string' && NATIVE_TYPES[(t as any).Type]) { return NATIVE_TYPES[(t as any).Type] } else if (isNativeTypeWithOperator(t) && NATIVE_TYPES[(t.Type as any).Type]) { diff --git a/packages/cddl2ts/tests/transform_edge_cases.test.ts b/packages/cddl2ts/tests/transform_edge_cases.test.ts index f0cb3d0..6611cb2 100644 --- a/packages/cddl2ts/tests/transform_edge_cases.test.ts +++ b/packages/cddl2ts/tests/transform_edge_cases.test.ts @@ -123,6 +123,62 @@ describe('transform edge cases', () => { expect(output).toContain('export type MaybeValue = unknown;') }) + it('should map simple wildcard regexp strings to template literal types', () => { + const output = transform([ + variable('channel', { + Type: 'tstr', + Operator: { + Type: 'regexp', + Value: literal('custom:.+') + } + } as any), + group('event-envelope', [ + property('channel', { + Type: 'tstr', + Operator: { + Type: 'regexp', + Value: literal('custom:.+') + } + } as any) + ]), + variable('email-address', { + Type: 'tstr', + Operator: { + Type: 'regexp', + Value: literal('[^@]+@[^@]+') + } + } as any), + variable('prefixed-name', { + Type: 'tstr', + Operator: { + Type: 'regexp', + Value: literal('foo_.+') + } + } as any), + variable('wrapped-name', { + Type: 'tstr', + Operator: { + Type: 'regexp', + Value: literal('some_.+_name') + } + } as any), + variable('double-wildcard', { + Type: 'tstr', + Operator: { + Type: 'regexp', + Value: literal('some_.+_middle_.+') + } + } as any) + ]) + + expect(output).toContain('export type Channel = `custom:${string}`;') + expect(output).toContain('channel: `custom:${string}`;') + expect(output).toContain('export type EmailAddress = string;') + expect(output).toContain('export type PrefixedName = `foo_${string}`;') + expect(output).toContain('export type WrappedName = `some_${string}_name`;') + expect(output).toContain('export type DoubleWildcard = `some_${string}_middle_${string}`;') + }) + it('should keep camelCase fields by default', () => { const output = transform([ group('session-capability-request', [