diff --git a/fixtures/components/string-intersection/code-editor/index.tsx b/fixtures/components/string-intersection/code-editor/index.tsx new file mode 100644 index 0000000..923d441 --- /dev/null +++ b/fixtures/components/string-intersection/code-editor/index.tsx @@ -0,0 +1,21 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; + +export namespace CodeEditorProps { + // This simulates the pattern used in the code editor where we want: + // 1. Autocomplete for known language literals + // 2. Allow custom string values + export type Language = 'javascript' | 'html' | 'ruby' | 'python' | 'java' | (string & { _?: undefined }); +} + +export interface CodeEditorProps { + /** + * Specifies the programming language. + */ + language: CodeEditorProps.Language; +} + +export default function CodeEditor({ language }: CodeEditorProps) { + return
Code Editor
; +} diff --git a/fixtures/components/string-intersection/tsconfig.json b/fixtures/components/string-intersection/tsconfig.json new file mode 100644 index 0000000..8afdfb3 --- /dev/null +++ b/fixtures/components/string-intersection/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["./**/*.tsx"] +} diff --git a/src/components/object-definition.ts b/src/components/object-definition.ts index 73f8e95..f6f607c 100644 --- a/src/components/object-definition.ts +++ b/src/components/object-definition.ts @@ -86,11 +86,25 @@ export function getObjectDefinition( return { type }; } -function getPrimitiveType(type: ts.UnionOrIntersectionType) { - if (type.types.every(subtype => subtype.isStringLiteral())) { +function isStringLiteralOrStringIntersection(subtype: ts.Type, checker: ts.TypeChecker): boolean { + // Check if it's a string literal + if (subtype.isStringLiteral() || subtype.flags & ts.TypeFlags.StringLiteral) { + return true; + } + if (subtype.isIntersection()) { + const stringified = stringifyType(subtype, checker); + if (stringified.startsWith('string &')) { + return true; + } + } + return false; +} + +function getPrimitiveType(type: ts.UnionOrIntersectionType, checker: ts.TypeChecker) { + if (type.types.every(subtype => isStringLiteralOrStringIntersection(subtype, checker))) { return 'string'; } - if (type.types.every(subtype => subtype.isNumberLiteral())) { + if (type.types.every(subtype => subtype.isNumberLiteral() || subtype.flags & ts.TypeFlags.NumberLiteral)) { return 'number'; } return undefined; @@ -103,10 +117,21 @@ function getUnionTypeDefinition( checker: ts.TypeChecker ): { type: string; inlineType: UnionTypeDefinition } { const valueDescriptions = extractValueDescriptions(realType, typeNode); - const primitiveType = getPrimitiveType(realType); - const values = realType.types.map(subtype => - primitiveType ? (subtype as ts.LiteralType).value.toString() : stringifyType(subtype, checker) - ); + const primitiveType = getPrimitiveType(realType, checker); + const values = realType.types.map(subtype => { + if (primitiveType === 'string') { + if (subtype.isStringLiteral()) { + return (subtype as ts.LiteralType).value.toString(); + } + if (subtype.isIntersection()) { + return 'string'; + } + } + if (primitiveType === 'number' && subtype.isNumberLiteral()) { + return (subtype as ts.LiteralType).value.toString(); + } + return stringifyType(subtype, checker); + }); return { type: primitiveType ?? realTypeName, diff --git a/test/components/object-definition.test.ts b/test/components/object-definition.test.ts new file mode 100644 index 0000000..3310c9a --- /dev/null +++ b/test/components/object-definition.test.ts @@ -0,0 +1,105 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { expect, test, beforeAll } from 'vitest'; +import { ComponentDefinition } from '../../src/components/interfaces'; +import { buildProject } from './test-helpers'; + +let simpleComponent: ComponentDefinition; +let complexTypesComponents: ComponentDefinition[]; + +beforeAll(() => { + const simpleResult = buildProject('simple'); + expect(simpleResult).toHaveLength(1); + [simpleComponent] = simpleResult; + + complexTypesComponents = buildProject('complex-types'); + expect(complexTypesComponents.length).toBeGreaterThan(0); +}); + +test('object definition should handle basic types', () => { + expect(simpleComponent.name).toBe('Simple'); + expect(simpleComponent.properties).toBeDefined(); +}); + +test('object definition should handle union types correctly', () => { + // Find a component with union types + const componentWithUnions = complexTypesComponents.find(comp => + comp.properties.some(prop => prop.inlineType?.type === 'union') + ); + + if (componentWithUnions) { + const unionProp = componentWithUnions.properties.find(def => def.inlineType?.type === 'union'); + + if (unionProp?.inlineType?.type === 'union') { + expect(unionProp.inlineType.values).toBeDefined(); + expect(Array.isArray(unionProp.inlineType.values)).toBe(true); + expect(unionProp.inlineType.values.length).toBeGreaterThan(0); + } + } +}); + +test('object definition should handle string literal unions', () => { + // Test string literal union handling + const componentWithStringUnions = complexTypesComponents.find(comp => + comp.properties.some(prop => prop.inlineType?.type === 'union' && prop.type === 'string') + ); + + if (componentWithStringUnions) { + const stringUnionProp = componentWithStringUnions.properties.find( + prop => prop.inlineType?.type === 'union' && prop.type === 'string' + ); + + if (stringUnionProp?.inlineType?.type === 'union') { + expect(stringUnionProp.type).toBe('string'); + expect(stringUnionProp.inlineType.values).toBeDefined(); + // Should contain string values + expect(stringUnionProp.inlineType.values.some(v => typeof v === 'string')).toBe(true); + } + } +}); + +test('object definition should handle number literal unions', () => { + // Test number literal union handling + const componentWithNumberUnions = complexTypesComponents.find(comp => + comp.properties.some(prop => prop.inlineType?.type === 'union' && prop.type === 'number') + ); + + if (componentWithNumberUnions) { + const numberUnionProp = componentWithNumberUnions.properties.find( + prop => prop.inlineType?.type === 'union' && prop.type === 'number' + ); + + if (numberUnionProp?.inlineType?.type === 'union') { + expect(numberUnionProp.type).toBe('number'); + expect(numberUnionProp.inlineType.values).toBeDefined(); + } + } +}); + +test('object definition should preserve type information', () => { + const props = simpleComponent.properties; + expect(props.length).toBeGreaterThan(0); + + props.forEach(prop => { + expect(prop.name).toBeDefined(); + expect(prop.type).toBeDefined(); + }); +}); + +test('object definition should handle mixed union types', () => { + // Test mixed union types (not primitive) + const componentWithMixedUnions = complexTypesComponents.find(comp => + comp.properties.some(prop => prop.inlineType?.type === 'union' && prop.type !== 'string' && prop.type !== 'number') + ); + + if (componentWithMixedUnions) { + const mixedUnionProp = componentWithMixedUnions.properties.find( + prop => prop.inlineType?.type === 'union' && prop.type !== 'string' && prop.type !== 'number' + ); + + if (mixedUnionProp?.inlineType?.type === 'union') { + expect(mixedUnionProp.inlineType.values).toBeDefined(); + expect(mixedUnionProp.inlineType.name).toBeDefined(); + } + } +}); diff --git a/test/components/string-intersection.test.ts b/test/components/string-intersection.test.ts new file mode 100644 index 0000000..30243d0 --- /dev/null +++ b/test/components/string-intersection.test.ts @@ -0,0 +1,101 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { expect, test, beforeAll } from 'vitest'; +import { ComponentDefinition } from '../../src/components/interfaces'; +import { buildProject } from './test-helpers'; + +let codeEditor: ComponentDefinition; + +beforeAll(() => { + const result = buildProject('string-intersection'); + expect(result).toHaveLength(1); + [codeEditor] = result; +}); + +test('should properly handle union types with string intersection for custom values', () => { + const languageProp = codeEditor.properties.find(def => def.name === 'language'); + + expect(languageProp?.name).toBe('language'); + expect(languageProp?.description).toBe('Specifies the programming language.'); + expect(languageProp?.optional).toBe(false); + expect(languageProp?.type).toBe('string'); + + // Check inline type structure + expect(languageProp?.inlineType?.name).toBe('CodeEditorProps.Language'); + expect(languageProp?.inlineType?.type).toBe('union'); + if (languageProp?.inlineType?.type === 'union') { + expect(languageProp.inlineType.valueDescriptions).toBeUndefined(); + } + + // The intersection type "string & { _?: undefined; }" should be converted to "string" + // String literal values should appear without quotes + const values = (languageProp?.inlineType as any)?.values; + expect(values).toHaveLength(6); + expect(values).toContain('javascript'); + expect(values).toContain('html'); + expect(values).toContain('ruby'); + expect(values).toContain('python'); + expect(values).toContain('java'); + expect(values).toContain('string'); // The intersection type becomes "string" to indicate custom values are allowed +}); + +test('should treat the union as primitive string type', () => { + const languageProp = codeEditor.properties.find(def => def.name === 'language'); + + // The type should be 'string' not the full union name + expect(languageProp?.type).toBe('string'); +}); + +test('should convert intersection helper to "string" in values array', () => { + const languageProp = codeEditor.properties.find(def => def.name === 'language'); + + // Should not contain the raw "string & { _?: undefined; }" syntax + const hasRawIntersectionType = + languageProp?.inlineType?.type === 'union' && + languageProp.inlineType.values.some((value: string) => value.includes('string &') || value.includes('_?:')); + + expect(hasRawIntersectionType).toBe(false); + + // But should contain "string" to indicate custom values are allowed + const hasStringValue = + languageProp?.inlineType?.type === 'union' && languageProp.inlineType.values.includes('string'); + + expect(hasStringValue).toBe(true); +}); + +test('should detect intersection types with string & pattern', () => { + const languageProp = codeEditor.properties.find(def => def.name === 'language'); + + // Verify that the union contains both string literals and the intersection type + expect(languageProp?.inlineType?.type).toBe('union'); + + if (languageProp?.inlineType?.type === 'union') { + const values = languageProp.inlineType.values; + + // Should have the literal values + expect(values).toContain('javascript'); + expect(values).toContain('html'); + expect(values).toContain('ruby'); + expect(values).toContain('python'); + expect(values).toContain('java'); + + // Should have 'string' representing the intersection type + expect(values).toContain('string'); + + // All values should be treated as string type (primitive detection) + expect(languageProp.type).toBe('string'); + } +}); + +test('should recognize intersection type as string-compatible', () => { + const languageProp = codeEditor.properties.find(def => def.name === 'language'); + + // The union with intersection type should be recognized as primitive string + expect(languageProp?.type).toBe('string'); + + if (languageProp?.inlineType?.type === 'union') { + // All values in the union should be compatible with string type + const allValuesAreStrings = languageProp.inlineType.values.every((value: string) => typeof value === 'string'); + expect(allValuesAreStrings).toBe(true); + } +});