From 4dc695303fa6f9fde67bcd8b5d7462264fbf89d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gustaf=20R=C3=A4ntil=C3=A4?= Date: Mon, 3 Apr 2023 08:14:21 +0200 Subject: [PATCH] feat(heritage): Add support for interface heritage This enables converting interfaces extending other interfaces from typescript: interface A extends B { ... } To in core-types let A become an and-type of ref B and an object. Reconstruction backwards works too. --- .../core-types-to-ts.test.ts.snap | 59 +++ lib/bi-directional.test.ts | 43 ++ lib/core-types-to-ts.test.ts | 191 +++++++++ lib/core-types-to-ts.ts | 106 ++++- lib/ts-to-core-types.test.ts | 389 +++++++++++++++++- lib/ts-to-core-types.ts | 102 +++-- 6 files changed, 853 insertions(+), 37 deletions(-) diff --git a/lib/__snapshots__/core-types-to-ts.test.ts.snap b/lib/__snapshots__/core-types-to-ts.test.ts.snap index 9f642c6..e345122 100644 --- a/lib/__snapshots__/core-types-to-ts.test.ts.snap +++ b/lib/__snapshots__/core-types-to-ts.test.ts.snap @@ -1,5 +1,64 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`core-types-to-ts and-type as empty interface with 1 heritage 1`] = ` +"export interface foo extends bar { +} + +export interface bar { + b: number; +} +" +`; + +exports[`core-types-to-ts and-type as empty interface with 2 heritage 1`] = ` +"export interface foo extends bar, baz { +} + +export interface bar { + b: number; +} + +export interface baz { + z: boolean; +} +" +`; + +exports[`core-types-to-ts and-type as non-empty interface with 1 heritage 1`] = ` +"export interface foo extends bar { + f: string; +} + +export interface bar { + b: number; +} +" +`; + +exports[`core-types-to-ts and-type as non-empty interface with 2 heritage 1`] = ` +"export interface foo extends bar, baz { + f: string; +} + +export interface bar { + b: number; +} + +export interface baz { + z: boolean; +} +" +`; + +exports[`core-types-to-ts and-type that cannot be an interface because non-object ref 1`] = ` +"export type foo = { + f?: string; +} & bar; + +export type bar = null; +" +`; + exports[`core-types-to-ts complex type 1`] = ` { "convertedTypes": [ diff --git a/lib/bi-directional.test.ts b/lib/bi-directional.test.ts index 4a5d0ce..abbc9cc 100644 --- a/lib/bi-directional.test.ts +++ b/lib/bi-directional.test.ts @@ -175,6 +175,49 @@ export type Bak = 17; `export type Foo_Bar_Baz = 42; export type Bak = 17; +` + ); + } ); + + it( "handle extending two interfaces (include-if-referenced)", ( ) => + { + const coreTypes = convertTypeScriptToCoreTypes( + ` + interface A { + a: 'a'; + } + export interface B { + b: 'b'; + } + export interface C extends A, B { + c: 'c'; + } + `, + { + nonExported: 'include-if-referenced' + } + ); + + const ts = convertCoreTypesToTypeScript( + coreTypes.data, + { + noDescriptiveHeader: true, + noDisableLintHeader: true, + } + ); + + expect( ts.data ).toBe( +`export interface B { + b: "b"; +} + +export interface C extends A, B { + c: "c"; +} + +export interface A { + a: "a"; +} ` ); } ); diff --git a/lib/core-types-to-ts.test.ts b/lib/core-types-to-ts.test.ts index 63abf68..aad3eb1 100644 --- a/lib/core-types-to-ts.test.ts +++ b/lib/core-types-to-ts.test.ts @@ -280,4 +280,195 @@ describe( "core-types-to-ts", ( ) => expect( ts.data ).toMatchSnapshot( ); } ); + + it( "and-type that cannot be an interface because non-object ref", ( ) => + { + const ts = convertCoreTypesToTypeScript( wrapDocument( [ + { + name: 'foo', + type: 'and', + and: [ + { + type: 'object', + properties: { + f: { required: false, node: { type: 'string' } }, + }, + additionalProperties: false, + }, + { + type: 'ref', + ref: 'bar', + }, + ], + }, + { + name: 'bar', + type: 'null', + }, + ] ), + { noDescriptiveHeader: true, noDisableLintHeader: true } + ); + + expect( ts.data ).toMatchSnapshot( ); + } ); + + it( "and-type as empty interface with 1 heritage", ( ) => + { + const ts = convertCoreTypesToTypeScript( wrapDocument( [ + { + name: 'foo', + type: 'and', + and: [ + { + type: 'object', + properties: { }, + additionalProperties: false, + }, + { + type: 'ref', + ref: 'bar', + }, + ], + }, + { + name: 'bar', + type: 'object', + properties: { + b: { required: true, node: { type: 'number' } }, + }, + additionalProperties: false, + }, + ] ), + { noDescriptiveHeader: true, noDisableLintHeader: true } + ); + + expect( ts.data ).toMatchSnapshot( ); + } ); + + it( "and-type as empty interface with 2 heritage", ( ) => + { + const ts = convertCoreTypesToTypeScript( wrapDocument( [ + { + name: 'foo', + type: 'and', + and: [ + { + type: 'object', + properties: { }, + additionalProperties: false, + }, + { + type: 'ref', + ref: 'bar', + }, + { + type: 'ref', + ref: 'baz', + }, + ], + }, + { + name: 'bar', + type: 'object', + properties: { + b: { required: true, node: { type: 'number' } }, + }, + additionalProperties: false, + }, + { + name: 'baz', + type: 'object', + properties: { + z: { required: true, node: { type: 'boolean' } }, + }, + additionalProperties: false, + }, + ] ), + { noDescriptiveHeader: true, noDisableLintHeader: true } + ); + + expect( ts.data ).toMatchSnapshot( ); + } ); + + it( "and-type as non-empty interface with 1 heritage", ( ) => + { + const ts = convertCoreTypesToTypeScript( wrapDocument( [ + { + name: 'foo', + type: 'and', + and: [ + { + type: 'object', + properties: { + f: { required: true, node: { type: 'string' } }, + }, + additionalProperties: false, + }, + { + type: 'ref', + ref: 'bar', + }, + ], + }, + { + name: 'bar', + type: 'object', + properties: { + b: { required: true, node: { type: 'number' } }, + }, + additionalProperties: false, + }, + ] ), + { noDescriptiveHeader: true, noDisableLintHeader: true } + ); + + expect( ts.data ).toMatchSnapshot( ); + } ); + + it( "and-type as non-empty interface with 2 heritage", ( ) => + { + const ts = convertCoreTypesToTypeScript( wrapDocument( [ + { + name: 'foo', + type: 'and', + and: [ + { + type: 'object', + properties: { + f: { required: true, node: { type: 'string' } }, + }, + additionalProperties: false, + }, + { + type: 'ref', + ref: 'bar', + }, + { + type: 'ref', + ref: 'baz', + }, + ], + }, + { + name: 'bar', + type: 'object', + properties: { + b: { required: true, node: { type: 'number' } }, + }, + additionalProperties: false, + }, + { + name: 'baz', + type: 'object', + properties: { + z: { required: true, node: { type: 'boolean' } }, + }, + additionalProperties: false, + }, + ] ), + { noDescriptiveHeader: true, noDisableLintHeader: true } + ); + + expect( ts.data ).toMatchSnapshot( ); + } ); } ); diff --git a/lib/core-types-to-ts.ts b/lib/core-types-to-ts.ts index 2f66ec5..1bd7871 100644 --- a/lib/core-types-to-ts.ts +++ b/lib/core-types-to-ts.ts @@ -34,6 +34,7 @@ const createdByUrl = 'https://github.com/grantila/core-types-ts'; interface Context { useUnknown: boolean; + rootTypes: Array< NamedType >; } function throwUnsupported( @@ -74,7 +75,11 @@ export function convertCoreTypesToTypeScript( { const { name } = node; - const tsNode = convertSingleCoreTypeToTypeScriptAst( node, opts ); + const ctx: Omit< Context, 'useUnknown' > = { + rootTypes: types, + }; + + const tsNode = convertSingleCoreType( node, opts, ctx ); convertedTypes.push( name ); @@ -107,6 +112,20 @@ export function convertSingleCoreTypeToTypeScriptAst( { } ) : { declaration: ts.Declaration; namespaceList: string[ ]; } +{ + const ctx: Omit< Context, 'useUnknown' > = { + rootTypes: [ ], + }; + + return convertSingleCoreType( node, opts, ctx ); +} + +export function convertSingleCoreType( + node: NamedType, + opts: Pick< ToTsOptions, 'useUnknown' | 'declaration' | 'namespaces' >, + partialCtx: Omit< Context, 'useUnknown' > +) +: { declaration: ts.Declaration; namespaceList: string[ ]; } { const { useUnknown = false, @@ -115,13 +134,14 @@ export function convertSingleCoreTypeToTypeScriptAst( } = opts; const ctx: Context = { + ...partialCtx, useUnknown, }; const { name, namespaces: namespaceList } = makeNameAndNamespace( node.name, namespaces ); - const ret = tsType( ctx, node ); + const ret = tsType( ctx, node, true ); const doExport = ( tsNode: ts.Declaration ) => wrapAnnotations( tsNode, node ); @@ -129,7 +149,7 @@ export function convertSingleCoreTypeToTypeScriptAst( const typeDeclaration = ret.type === 'flow-type' ? declareType( declaration, name, ret.node ) - : declareInterface( declaration, name, ret.properties ); + : declareInterface( declaration, name, ret.properties, ret.inherits ); return { declaration: doExport( typeDeclaration ), @@ -179,14 +199,30 @@ function declareType( declaration: boolean, name: string, node: ts.TypeNode ) function declareInterface( declaration: boolean, name: string, - nodes: Array< ts.TypeElement > + nodes: Array< ts.TypeElement >, + inherits: Array< string > ) { + const heritage: ts.HeritageClause[] | undefined = + inherits.length === 0 + ? undefined + : [ + factory.createHeritageClause( + ts.SyntaxKind.ExtendsKeyword, + inherits.map( name => + factory.createExpressionWithTypeArguments( + factory.createIdentifier( name ), + undefined // type arguments + ) + ) + ) + ]; + return factory.createInterfaceDeclaration( createExportModifier( declaration ), // modifiers factory.createIdentifier( name ), undefined, // type parameters - undefined, // heritage + heritage, nodes ); } @@ -195,6 +231,7 @@ interface TsTypeReturnAsObject { type: 'object'; node: ts.TypeLiteralNode; properties: Array< ts.TypeElement >; + inherits: Array< string >; } interface TsTypeReturnAsFlowType { type: 'flow-type'; @@ -243,8 +280,11 @@ function tsAny( ctx: Context ): ts.TypeNode : factory.createKeywordTypeNode( ts.SyntaxKind.AnyKeyword ); } -function tsType( ctx: Context, node: NodeType ): TsTypeReturn +function tsType( ctx: Context, node: NodeType, topLevel = false ): TsTypeReturn { + if ( topLevel && node.type === 'and' && isObjectWithHeritage( ctx, node ) ) + return { type: 'object', ...tsObjectTypeWithWithHeritage( ctx, node ) }; + if ( node.type === 'and' || node.type === 'or' ) return { type: 'flow-type', node: tsTypeAndOr( ctx, node ) }; @@ -335,7 +375,7 @@ function tsConstType( ctx: Context, node: NodeType, value: any ): ts.TypeNode throwUnsupported( `Invalid const value: "${value}"`, node, - { blob: value } + { blob: value } ); } )( ); } @@ -410,7 +450,57 @@ function tsObjectType( ctx: Context, node: ObjectType ) const objectAsNode = factory.createTypeLiteralNode( propertyNodes ); - return { properties: propertyNodes, node: objectAsNode }; + return { properties: propertyNodes, node: objectAsNode, inherits: [ ] }; +} + +// Extracts objects and refs from an and-type. +// Only refs that themselves are objects. +function getObjectsAndRefs( ctx: Context, node: AndType ) +{ + const objects = node.and.filter( + ( node ): node is ObjectType => node.type === 'object' + ); + const refs = node.and.filter( + ( node ): node is RefType => + node.type === 'ref' + && + ctx.rootTypes.some( rootNode => + rootNode.name === node.ref + && + rootNode.type === 'object' + ) + ); + + return { objects, refs }; +} + +function isObjectWithHeritage( ctx: Context, node: AndType ): boolean +{ + const { objects, refs } = getObjectsAndRefs( ctx, node ); + + if ( objects.length !== 0 && objects.length !== 1 ) + // Must have zero or one object with properties, not multiple + return false; + + // And-type contains only refs and (maybe) an object, so it's an interface + return objects.length + refs.length === node.and.length; +} + +function tsObjectTypeWithWithHeritage( ctx: Context, node: AndType ) +: Omit< TsTypeReturnAsObject, 'type' > +{ + const { objects, refs } = getObjectsAndRefs( ctx, node ); + + const ret: ReturnType< typeof tsObjectType > = + objects.length === 0 + ? { + properties: [ ], + node: factory.createTypeLiteralNode( [ ] ), + inherits: [ ], + } + : tsObjectType( ctx, objects[ 0 ] ) + + return { ...ret, inherits: refs.map( node => node.ref ) }; } function tsSpreadType( ctx: Context, node: NodeType ): ts.TypeNode diff --git a/lib/ts-to-core-types.test.ts b/lib/ts-to-core-types.test.ts index 8075602..130f44d 100644 --- a/lib/ts-to-core-types.test.ts +++ b/lib/ts-to-core-types.test.ts @@ -472,7 +472,7 @@ describe( "non-exported types", ( ) => type: 'object', properties: { prop: { - node: { type: 'ref', ref: 'T', title: 'T2.prop' }, + node: { type: 'any', title: 'T2.prop' }, required: true, }, }, @@ -1342,7 +1342,7 @@ describe( "partial", ( ) => } ); } ); -describe( "comples partial/pick/omit", ( ) => +describe( "complex partial/pick/omit", ( ) => { it( "handle complex deep case", ( ) => { @@ -1393,3 +1393,388 @@ describe( "comples partial/pick/omit", ( ) => ] ); } ); } ); + +describe( "extended interfaces", ( ) => +{ + it( "handle extending one interface (ignore)", ( ) => + { + const coreTypes = convertTypeScriptToCoreTypes( + ` + interface A { + a: 'a'; + } + export interface B extends A { + b: 'b'; + } + `, + { + nonExported: 'ignore' + } + ).data.types; + + equal( coreTypes, [ + { + name: 'B', + title: 'B', + type: 'object', + properties: { + b: { + node: { + type: 'string', + const: 'b', + title: 'B.b', + }, + required: true, + }, + }, + additionalProperties: false, + }, + ] ); + } ); + + it( "handle extending one interface (inline)", ( ) => + { + const coreTypes = convertTypeScriptToCoreTypes( + ` + interface A { + a: 'a'; + } + export interface B extends A { + b: 'b'; + } + `, + { + nonExported: 'inline' + } + ).data.types; + + equal( coreTypes, [ + { + name: 'B', + title: 'B', + type: 'and', + and: [ + { + name: 'A', + title: 'A', + type: 'object', + properties: { + a: { + node: { + type: 'string', + const: 'a', + title: 'A.a', + }, + required: true, + }, + }, + additionalProperties: false, + }, + { + type: 'object', + properties: { + b: { + node: { + type: 'string', + const: 'b', + title: 'B.b', + }, + required: true, + }, + }, + additionalProperties: false, + }, + ], + }, + ] ); + } ); + + it( "handle extending one interface (include-if-referenced)", ( ) => + { + const coreTypes = convertTypeScriptToCoreTypes( + ` + interface A { + a: 'a'; + } + export interface B extends A { + b: 'b'; + } + `, + { + nonExported: 'include-if-referenced' + } + ).data.types; + + equal( coreTypes, [ + { + name: 'B', + title: 'B', + type: 'and', + and: [ + { + type: 'ref', + ref: 'A', + }, + { + type: 'object', + properties: { + b: { + node: { + type: 'string', + const: 'b', + title: 'B.b', + }, + required: true, + }, + }, + additionalProperties: false, + }, + ], + }, + { + name: 'A', + title: 'A', + type: 'object', + properties: { + a: { + node: { + type: 'string', + const: 'a', + title: 'A.a', + }, + required: true, + }, + }, + additionalProperties: false, + }, + ] ); + } ); + + it( "handle extending two interfaces (ignore)", ( ) => + { + const coreTypes = convertTypeScriptToCoreTypes( + ` + interface A { + a: 'a'; + } + export interface B { + b: 'b'; + } + export interface C extends A, B { + c: 'c'; + } + `, + { + nonExported: 'ignore' + } + ).data.types; + + equal( coreTypes, [ + { + name: 'B', + title: 'B', + type: 'object', + properties: { + b: { + node: { + type: 'string', + const: 'b', + title: 'B.b', + }, + required: true, + }, + }, + additionalProperties: false, + }, + { + name: 'C', + title: 'C', + type: 'and', + and: [ + { + type: 'ref', + ref: 'B', + }, + { + type: 'object', + properties: { + c: { + node: { + type: 'string', + const: 'c', + title: 'C.c', + }, + required: true, + }, + }, + additionalProperties: false, + }, + ], + }, + ] ); + } ); + + it( "handle extending two interfaces (inline)", ( ) => + { + const coreTypes = convertTypeScriptToCoreTypes( + ` + interface A { + a: 'a'; + } + export interface B { + b: 'b'; + } + export interface C extends A, B { + c: 'c'; + } + `, + { + nonExported: 'inline' + } + ).data.types; + + equal( coreTypes, [ + { + name: 'B', + title: 'B', + type: 'object', + properties: { + b: { + node: { + type: 'string', + const: 'b', + title: 'B.b', + }, + required: true, + }, + }, + additionalProperties: false, + }, + { + name: 'C', + title: 'C', + type: 'and', + and: [ + { + name: 'A', + title: 'A', + type: 'object', + properties: { + a: { + node: { + type: 'string', + const: 'a', + title: 'A.a', + }, + required: true, + }, + }, + additionalProperties: false, + }, + { + type: 'ref', + ref: 'B', + }, + { + type: 'object', + properties: { + c: { + node: { + type: 'string', + const: 'c', + title: 'C.c', + }, + required: true, + }, + }, + additionalProperties: false, + }, + ], + }, + ] ); + } ); + + it( "handle extending two interfaces (include-if-referenced)", ( ) => + { + const coreTypes = convertTypeScriptToCoreTypes( + ` + interface A { + a: 'a'; + } + export interface B { + b: 'b'; + } + export interface C extends A, B { + c: 'c'; + } + `, + { + nonExported: 'include-if-referenced' + } + ).data.types; + + equal( coreTypes, [ + { + name: 'B', + title: 'B', + type: 'object', + properties: { + b: { + node: { + type: 'string', + const: 'b', + title: 'B.b', + }, + required: true, + }, + }, + additionalProperties: false, + }, + { + name: 'C', + title: 'C', + type: 'and', + and: [ + { + type: 'ref', + ref: 'A', + }, + { + type: 'ref', + ref: 'B', + }, + { + type: 'object', + properties: { + c: { + node: { + type: 'string', + const: 'c', + title: 'C.c', + }, + required: true, + }, + }, + additionalProperties: false, + }, + ], + }, + { + name: 'A', + title: 'A', + type: 'object', + properties: { + a: { + node: { + type: 'string', + const: 'a', + title: 'A.a', + }, + required: true, + }, + }, + additionalProperties: false, + }, + ] ); + } ); +} ); diff --git a/lib/ts-to-core-types.ts b/lib/ts-to-core-types.ts index ebe0992..8064a68 100644 --- a/lib/ts-to-core-types.ts +++ b/lib/ts-to-core-types.ts @@ -41,6 +41,7 @@ interface Context cyclicState: Set< string >; getUnsupportedError( message: string, node: ts.Node ): UnsupportedError; handleError( err: UnsupportedError ): undefined | never; + ensureNonCyclic( name: string, node: ts.Node ): void; } const defaultWarn = ( sourceCode: string ): WarnFunction => @@ -262,7 +263,19 @@ export function convertTypeScriptToCoreTypes( throw err; return undefined; - } + }, + ensureNonCyclic( name: string, node: ts.Node ) + { + if ( ctx.cyclicState.has( name ) ) + throw new MalformedTypeError( + `Cyclic type found when trying to inline type ${name}`, + { + blob: node, + loc: toLocation( node ), + } + ); + ctx.cyclicState.add( name ); + }, }; if ( ctx.options.nonExported === 'fail' ) @@ -352,6 +365,29 @@ function fromTsTopLevelNode( node: TopLevelDeclaration, ctx: Context ) } else if ( ts.isInterfaceDeclaration( node ) ) { + const heritage = getInterfaceHeritage( node ); + + // This is an extended interface, which we turn into an and-type of the + // object itself and the refs it extends. + // If no such ref was found, we keep it as an interface. + const inherited: Array< NodeType > = heritage + .map( ref => getRefType( node, ref, ctx ) ) + .filter( isNonNullable ); + + if ( inherited.length > 0 ) + return { + name: node.name.getText( ), + type: 'and', + and: [ + ...inherited, + { + type: 'object', + ...fromTsObjectMembers( node, ctx ), + }, + ], + ...decorateNode( node ), + }; + return { name: node.name.getText( ), type: 'object', @@ -363,6 +399,15 @@ function fromTsTopLevelNode( node: TopLevelDeclaration, ctx: Context ) throw new Error( "Internal error" ); } +function getInterfaceHeritage( node: ts.InterfaceDeclaration ): Array< string > +{ + const heritage = node.heritageClauses ?? [ ]; + if ( heritage.length === 0 ) + return [ ]; + + return heritage[ 0 ].types.map( type => type.getText( ) ); +} + function isOptionalProperty( node: ts.PropertySignature ) { return node.questionToken?.kind === ts.SyntaxKind.QuestionToken; @@ -525,19 +570,6 @@ function fromTsTypeNode( const ref = node.typeName.text; - const ensureNonCyclic = ( name: string ) => - { - if ( ctx.cyclicState.has( name ) ) - throw new MalformedTypeError( - `Cyclic type found when trying to inline type ${name}`, - { - blob: node, - loc: toLocation( node ), - } - ); - ctx.cyclicState.add( name ); - }; - // TODO: Make this able to go into named type. // It currently understands: // 'foo' | 'bar' @@ -614,7 +646,7 @@ function fromTsTypeNode( node ) ); - ensureNonCyclic( refName ); + ctx.ensureNonCyclic( refName, node ); const members = fromTsObjectMembers( reference.declaration, ctx ); return { members, secondNames }; @@ -719,19 +751,12 @@ function fromTsTypeNode( return handleGeneric( node, ctx ); // TODO: Handle (reconstruct) generics - const typeInfo = ctx.typeMap.get( ref ); - if ( typeInfo && !typeInfo.exported && !peekOnly ) - { - if ( ctx.options.nonExported === 'include-if-referenced' ) - ctx.includeExtra.add( ref ); - else if ( ctx.options.nonExported === 'inline' ) - { - ensureNonCyclic( ref ); - return fromTsTopLevelNode( typeInfo.declaration, ctx ); - } - } + if ( peekOnly ) + return { type: 'ref', ref, ...decorateNode( node ) }; + + const refNode = getRefType( node, ref, ctx ); - return { type: 'ref', ref, ...decorateNode( node ) }; + return !refNode ? undefined : { ...refNode, ...decorateNode( node ) }; } else if ( ts.isTupleTypeNode( node ) ) @@ -816,6 +841,29 @@ function fromTsTypeNode( } } +function getRefType( node: ts.Node, ref: string, ctx: Context ) +: NodeType | undefined +{ + const typeInfo = ctx.typeMap.get( ref ); + if ( typeInfo && !typeInfo.exported ) + { + if ( ctx.options.nonExported === 'include-if-referenced' ) + { + ctx.includeExtra.add( ref ); + return { type: 'ref', ref }; + } + else if ( ctx.options.nonExported === 'inline' ) + { + ctx.ensureNonCyclic( ref, node ); + return fromTsTopLevelNode( typeInfo.declaration, ctx ); + } + } + else if ( typeInfo ) + return { type: 'ref', ref }; + else + return undefined; +} + function fromTsTuple( node: ts.TupleTypeNode, ctx: Context ) : Pick< TupleType, 'elementTypes' | 'additionalItems' | 'minItems' > {