From d64c2c2d978275f04e75b7f38d03b96e6f622e68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Kautler?= Date: Tue, 17 Sep 2024 17:11:59 +0200 Subject: [PATCH] Transform numeric unions to types like string unions --- .../plugins/NullableUnionTypePlugin.ts | 2 + .../plugins/NumericUnionTypePlugin.ts | 175 ++++++++++++++++++ .../plugins/convertParameterDeclaration.ts | 2 + .../plugins/convertTypeAliasDeclaration.ts | 5 + src/defaultPlugins.ts | 2 + test/functional/base/base.test.ts | 3 + .../base/generated/union/numericEnum.kt | 66 +++++++ .../base/lib/union/numericEnum.d.ts | 9 + 8 files changed, 264 insertions(+) create mode 100644 src/converter/plugins/NumericUnionTypePlugin.ts create mode 100644 test/functional/base/generated/union/numericEnum.kt create mode 100644 test/functional/base/lib/union/numericEnum.d.ts diff --git a/src/converter/plugins/NullableUnionTypePlugin.ts b/src/converter/plugins/NullableUnionTypePlugin.ts index 6b66bae6..5eb252e4 100644 --- a/src/converter/plugins/NullableUnionTypePlugin.ts +++ b/src/converter/plugins/NullableUnionTypePlugin.ts @@ -6,6 +6,7 @@ import {Render, renderResolvedNullable} from "../render.js"; import {TypeScriptService, typeScriptServiceKey} from "./TypeScriptPlugin.js"; import {resolveParenthesizedType} from "../../utils/resolveParenthesizedType.js"; import {isNullableStringUnionType} from "./StringUnionTypePlugin.js"; +import {isNullableNumericUnionType} from "./NumericUnionTypePlugin.js"; import {GeneratedFile} from "../generated.js"; const isNull = (type: Node) => ts.isLiteralTypeNode(type) && type.literal.kind === ts.SyntaxKind.NullKeyword @@ -134,6 +135,7 @@ export class NullableUnionTypePlugin implements ConverterPlugin { } if (isNullableStringUnionType(node, context)) return null + if (isNullableNumericUnionType(node, context)) return null if (isNullableUnionType(node, context)) { const types = flatUnionTypes(node, context) diff --git a/src/converter/plugins/NumericUnionTypePlugin.ts b/src/converter/plugins/NumericUnionTypePlugin.ts new file mode 100644 index 00000000..f219fc31 --- /dev/null +++ b/src/converter/plugins/NumericUnionTypePlugin.ts @@ -0,0 +1,175 @@ +import ts, {LiteralTypeNode, Node, NumericLiteral, PrefixUnaryExpression, SyntaxKind, UnionTypeNode} from "typescript"; +import {escapeIdentifier} from "../../utils/strings.js"; +import {CheckCoverageService, checkCoverageServiceKey} from "./CheckCoveragePlugin.js"; +import {ConfigurationService, configurationServiceKey} from "./ConfigurationPlugin.js"; +import {ConverterContext} from "../context.js"; +import {createAnonymousDeclarationPlugin} from "./AnonymousDeclarationPlugin.js"; +import {flatUnionTypes, isNullableType} from "./NullableUnionTypePlugin.js"; +import {ifPresent, Render} from "../render.js"; +import {InjectionService, injectionServiceKey} from "./InjectionPlugin.js"; +import {InjectionType} from "../injection.js"; +import {TypeScriptService, typeScriptServiceKey} from "./TypeScriptPlugin.js"; +import {NamespaceInfoService, namespaceInfoServiceKey} from "./NamespaceInfoPlugin.js"; + +export function isNumericUnionType(node: ts.Node, context: ConverterContext): node is UnionTypeNode { + return ( + ts.isUnionTypeNode(node) + && flatUnionTypes(node, context).every(type => ( + ts.isLiteralTypeNode(type) + && (ts.isNumericLiteral(type.literal) + || (ts.isPrefixUnaryExpression(type.literal) + && ts.isNumericLiteral(type.literal.operand))) + )) + ) +} + +export function isNullableNumericUnionType(node: ts.Node, context: ConverterContext): node is UnionTypeNode { + if (!ts.isUnionTypeNode(node)) return false + + const types = flatUnionTypes(node, context) + const nonNullableTypes = types.filter(type => !isNullableType(type)) + + return ( + types.every(type => ( + isNullableType(type) + || ( + ts.isLiteralTypeNode(type) + && (ts.isNumericLiteral(type.literal) + || (ts.isPrefixUnaryExpression(type.literal) + && ts.isNumericLiteral(type.literal.operand))) + ) + )) + && nonNullableTypes.length > 1 + ) +} + +export function convertNumericUnionType( + node: UnionTypeNode, + name: string, + isInlined: boolean, + context: ConverterContext, + render: Render, +) { + const checkCoverageService = context.lookupService(checkCoverageServiceKey) + checkCoverageService?.cover(node) + + const configurationService = context.lookupService(configurationServiceKey) + if (configurationService === undefined) throw new Error("ConfigurationService required") + const typeScriptService = context.lookupService(typeScriptServiceKey) + if (typeScriptService === undefined) throw new Error("TypeScriptService required") + const namespaceInfoService = context.lookupService(namespaceInfoServiceKey) + const injectionService = context.lookupService(injectionServiceKey) + + const types = flatUnionTypes(node, context) + + const nonNullableTypes = types.filter(type => !isNullableType(type)) + const nullableTypes = types.filter(type => isNullableType(type)) + + const {unionNameMapper} = configurationService.configuration + + const entries = nonNullableTypes + .filter((type): type is LiteralTypeNode => ts.isLiteralTypeNode(type)) + .map(type => { + checkCoverageService?.cover(type) + + return type.literal + }) + .filter((literal): literal is NumericLiteral | PrefixUnaryExpression => + ts.isNumericLiteral(literal) + || (ts.isPrefixUnaryExpression(literal) + && ts.isNumericLiteral(literal.operand)) + ) + .map(literal => { + checkCoverageService?.cover(literal) + + const value = typeScriptService.printNode(literal) + + for (const [namePattern, valueMapping] of Object.entries(unionNameMapper)) { + const nameRegexp = new RegExp(namePattern) + if (nameRegexp.test(name)) { + for (const [valuePattern, key] of Object.entries(valueMapping)) { + const valueRegexp = new RegExp(valuePattern) + if (valueRegexp.test(value)) { + if (!key) { + throw new Error("Configured key in unionNameMapper must not be empty") + } + return [key, value] as const + } + } + } + } + + if (ts.isNumericLiteral(literal)) { + return [`VALUE_${toIdentifierPart(typeScriptService, literal)}`, value] as const + } else { + return [`VALUE_MINUS_${toIdentifierPart(typeScriptService, literal.operand)}`, value] as const + } + }) + + const keyDisambiguators: Map = new Map() + const disambiguatedEntries = entries.map(([key, value]) => { + const keyDisambiguator = (keyDisambiguators.get(key) ?? 0) + 1 + keyDisambiguators.set(key, keyDisambiguator) + if (keyDisambiguator > 1) { + return [escapeIdentifier(`${key}_${keyDisambiguator}`), value] as const; + } else { + return [escapeIdentifier(key), value] as const; + } + }); + + const body = disambiguatedEntries + .map(([key, value]) => ( + ` +@seskar.js.JsValue("${value}") +val ${key}: ${name} + `.trim() + )) + .join("\n") + + const heritageInjections = injectionService?.resolveInjections(node, InjectionType.HERITAGE_CLAUSE, context, render) + + const namespace = typeScriptService.findClosest(node, ts.isModuleDeclaration) + + let externalModifier = "external " + + if (isInlined && namespace !== undefined && namespaceInfoService?.resolveNamespaceStrategy(namespace) === "object") { + externalModifier = "" + } + + const injectedHeritageClauses = heritageInjections + ?.filter(Boolean) + ?.join(", ") + + const declaration = ` +sealed ${externalModifier}interface ${name}${ifPresent(injectedHeritageClauses, it => ` : ${it}`)} { +companion object { +${body} +} +} + `.trim() + + const nullable = nullableTypes.length > 0 + + return { + declaration, + nullable, + } +} + +function toIdentifierPart(typeScriptService: TypeScriptService, node: Node): string { + return typeScriptService.printNode(node).replaceAll(".", "_") +} + +export const numericUnionTypePlugin = createAnonymousDeclarationPlugin( + (node, context, render) => { + if (!isNullableNumericUnionType(node, context)) return null + + const name = context.resolveName(node) + + const {declaration, nullable} = convertNumericUnionType(node, name, false, context, render) + + const reference = nullable ? `${name}?` : name + + return {name, declaration, reference}; + } +) diff --git a/src/converter/plugins/convertParameterDeclaration.ts b/src/converter/plugins/convertParameterDeclaration.ts index 2dacfbf7..aa4ef3eb 100644 --- a/src/converter/plugins/convertParameterDeclaration.ts +++ b/src/converter/plugins/convertParameterDeclaration.ts @@ -3,6 +3,7 @@ import {createSimplePlugin} from "../plugin.js"; import {CheckCoverageService, checkCoverageServiceKey} from "./CheckCoveragePlugin.js"; import {flatUnionTypes, isNullableType, isNullableUnionType} from "./NullableUnionTypePlugin.js"; import {isNullableStringUnionType} from "./StringUnionTypePlugin.js"; +import {isNullableNumericUnionType} from "./NumericUnionTypePlugin.js"; import {ConverterContext} from "../context.js"; import {Render, renderNullable} from "../render.js"; import {escapeIdentifier} from "../../utils/strings.js"; @@ -209,6 +210,7 @@ const expandUnions = ( if (isThisParameter(parameter)) continue if (type && isNullableStringUnionType(type, context)) continue + if (type && isNullableNumericUnionType(type, context)) continue if (type && ts.isUnionTypeNode(type)) { checkCoverageService?.cover(type) diff --git a/src/converter/plugins/convertTypeAliasDeclaration.ts b/src/converter/plugins/convertTypeAliasDeclaration.ts index 720977b2..0c073878 100644 --- a/src/converter/plugins/convertTypeAliasDeclaration.ts +++ b/src/converter/plugins/convertTypeAliasDeclaration.ts @@ -3,6 +3,7 @@ import {createSimplePlugin} from "../plugin.js"; import {ifPresent} from "../render.js"; import {CheckCoverageService, checkCoverageServiceKey} from "./CheckCoveragePlugin.js"; import {convertStringUnionType, isStringUnionType} from "./StringUnionTypePlugin.js"; +import {convertNumericUnionType, isNumericUnionType} from "./NumericUnionTypePlugin.js"; import {convertTypeLiteral} from "./TypeLiteralPlugin.js"; import {convertInheritedTypeLiteral, isInheritedTypeLiteral} from "./InheritedTypeLiteralPlugin.js"; import {convertMappedType} from "./MappedTypePlugin.js"; @@ -39,6 +40,10 @@ export const convertTypeAliasDeclaration = createSimplePlugin((node, context, re return convertStringUnionType(node.type, name, true, context, render).declaration } + if (isNumericUnionType(node.type, context)) { + return convertNumericUnionType(node.type, name, true, context, render).declaration + } + if (isInheritedTypeLiteral(node.type)) { return convertInheritedTypeLiteral(node.type, name, typeParameters, true, context, render) } diff --git a/src/defaultPlugins.ts b/src/defaultPlugins.ts index 72d463b1..34eb5d4e 100644 --- a/src/defaultPlugins.ts +++ b/src/defaultPlugins.ts @@ -55,6 +55,7 @@ import {convertIndexedSignatureDeclaration} from "./converter/plugins/convertInd import {DeclarationMergingPlugin} from "./converter/plugins/DeclarationMergingPlugin.js"; import {convertTypeQuery} from "./converter/plugins/convertTypeQuery.js"; import {stringUnionTypePlugin} from "./converter/plugins/StringUnionTypePlugin.js"; +import {numericUnionTypePlugin} from "./converter/plugins/NumericUnionTypePlugin.js"; import {convertLiteral} from "./converter/plugins/convertLiteral.js"; import {inheritedTypeLiteralPlugin} from "./converter/plugins/InheritedTypeLiteralPlugin.js"; import {Injection} from "./converter/injection.js"; @@ -96,6 +97,7 @@ export const createPlugins = ( typeLiteralPlugin, mappedTypePlugin, stringUnionTypePlugin, + numericUnionTypePlugin, inheritedTypeLiteralPlugin, convertPrimitive(hasKind(ts.SyntaxKind.AnyKeyword), () => "Any?"), diff --git a/test/functional/base/base.test.ts b/test/functional/base/base.test.ts index 84af8e5f..b0169951 100644 --- a/test/functional/base/base.test.ts +++ b/test/functional/base/base.test.ts @@ -17,6 +17,9 @@ testGeneration("base", import.meta.url, output => ({ }, "^Operator": { "^>=$": "GTE" + }, + "^AnotherCompareResult2$": { + "": "RESULT" } }, verbose: true, diff --git a/test/functional/base/generated/union/numericEnum.kt b/test/functional/base/generated/union/numericEnum.kt new file mode 100644 index 00000000..4254a6a4 --- /dev/null +++ b/test/functional/base/generated/union/numericEnum.kt @@ -0,0 +1,66 @@ +// Generated by Karakum - do not modify it manually! + +@file:JsModule("sandbox-base/union/numericEnum") +@file:Suppress( + "NON_EXTERNAL_DECLARATION_IN_INAPPROPRIATE_FILE", +) + +package sandbox.base.union + +external fun compare(): CompareResult + +sealed external interface AnotherCompareResult { +companion object { +@seskar.js.JsValue("-1") +val VALUE_MINUS_1: AnotherCompareResult +@seskar.js.JsValue("0") +val VALUE_0: AnotherCompareResult +@seskar.js.JsValue("1") +val VALUE_1: AnotherCompareResult +} +} + +external fun foo(param: FooParam) + +sealed external interface AnotherCompareResult2 { +companion object { +@seskar.js.JsValue("-1") +val RESULT: AnotherCompareResult2 +@seskar.js.JsValue("0") +val RESULT_2: AnotherCompareResult2 +@seskar.js.JsValue("1") +val RESULT_3: AnotherCompareResult2 +} +} + +sealed external interface AnotherCompareResult3 { +companion object { +@seskar.js.JsValue("-1.0") +val VALUE_MINUS_1_0: AnotherCompareResult3 +@seskar.js.JsValue("0") +val VALUE_0: AnotherCompareResult3 +@seskar.js.JsValue("1.") +val VALUE_1_: AnotherCompareResult3 +} +} +sealed external interface CompareResult { +companion object { +@seskar.js.JsValue("-1") +val VALUE_MINUS_1: CompareResult +@seskar.js.JsValue("0") +val VALUE_0: CompareResult +@seskar.js.JsValue("1") +val VALUE_1: CompareResult +} +} + +sealed external interface FooParam { +companion object { +@seskar.js.JsValue("-1") +val VALUE_MINUS_1: FooParam +@seskar.js.JsValue("0") +val VALUE_0: FooParam +@seskar.js.JsValue("1") +val VALUE_1: FooParam +} +} \ No newline at end of file diff --git a/test/functional/base/lib/union/numericEnum.d.ts b/test/functional/base/lib/union/numericEnum.d.ts new file mode 100644 index 00000000..f23f00af --- /dev/null +++ b/test/functional/base/lib/union/numericEnum.d.ts @@ -0,0 +1,9 @@ +export declare function compare(): -1 | 0 | 1 + +export declare type AnotherCompareResult = -1 | 0 | 1 + +export function foo(param: -1 | 0 | 1); + +export declare type AnotherCompareResult2 = -1 | 0 | 1 + +export declare type AnotherCompareResult3 = -1.0 | 0 | 1.