diff --git a/compiler/ir/backend.js/src/org/jetbrains/kotlin/ir/backend/js/export/ExportModelToTsDeclarations.kt b/compiler/ir/backend.js/src/org/jetbrains/kotlin/ir/backend/js/export/ExportModelToTsDeclarations.kt index d10adad6409..cc615af1cdb 100644 --- a/compiler/ir/backend.js/src/org/jetbrains/kotlin/ir/backend/js/export/ExportModelToTsDeclarations.kt +++ b/compiler/ir/backend.js/src/org/jetbrains/kotlin/ir/backend/js/export/ExportModelToTsDeclarations.kt @@ -7,14 +7,18 @@ package org.jetbrains.kotlin.ir.backend.js.export import org.jetbrains.kotlin.descriptors.DescriptorVisibilities import org.jetbrains.kotlin.ir.backend.js.JsLoweredDeclarationOrigin +import org.jetbrains.kotlin.ir.backend.js.utils.JsAnnotations import org.jetbrains.kotlin.ir.backend.js.utils.getFqNameWithJsNameWhenAvailable import org.jetbrains.kotlin.ir.backend.js.utils.getJsNameOrKotlinName import org.jetbrains.kotlin.ir.backend.js.utils.sanitizeName import org.jetbrains.kotlin.ir.declarations.IrClass +import org.jetbrains.kotlin.ir.util.hasAnnotation import org.jetbrains.kotlin.ir.util.isObject import org.jetbrains.kotlin.ir.util.parentAsClass +import org.jetbrains.kotlin.ir.util.primaryConstructor import org.jetbrains.kotlin.js.common.isValidES5Identifier import org.jetbrains.kotlin.serialization.js.ModuleKind +import org.jetbrains.kotlin.utils.addToStdlib.firstIsInstanceOrNull import org.jetbrains.kotlin.utils.addToStdlib.runIf private const val Nullable = "Nullable" @@ -306,12 +310,24 @@ class ExportModelToTsDeclarations { val bodyString = privateCtorString + membersString + indent val nestedClasses = nonInnerClasses + innerClasses.map { it.withProtectedConstructors() } + val tsIgnoreForPrivateConstructorInheritance = if (hasSuperClassWithPrivateConstructor()) { + tsIgnore("extends class with private primary constructor") + "\n$indent" + } else "" + val klassExport = "$prefix$modifiers$keyword $name$renderedTypeParameters$superClassClause$superInterfacesClause {\n$bodyString}" val staticsExport = if (nestedClasses.isNotEmpty()) "\n" + ExportedNamespace(name, nestedClasses).toTypeScript(indent, prefix) else "" - return if (name.isValidES5Identifier()) klassExport + staticsExport else "" + return if (name.isValidES5Identifier()) tsIgnoreForPrivateConstructorInheritance + klassExport + staticsExport else "" + } + + private fun ExportedRegularClass.hasSuperClassWithPrivateConstructor(): Boolean { + return superClasses.firstIsInstanceOrNull() + ?.ir + ?.takeIf { !it.isObject } + ?.primaryConstructor + ?.let { it.visibility == DescriptorVisibilities.PRIVATE || it.hasAnnotation(JsAnnotations.jsExportIgnoreFqn) } ?: false } private fun List.toExtendsClause(indent: String): String { @@ -451,4 +467,8 @@ class ExportModelToTsDeclarations { it.couldBeProperty() && it.ir.visibility != DescriptorVisibilities.PROTECTED } } + + private fun tsIgnore(reason: String): String { + return "/* @ts-ignore: $reason */" + } } diff --git a/js/js.tests/build.gradle.kts b/js/js.tests/build.gradle.kts index 641c6b2cc59..04987612623 100644 --- a/js/js.tests/build.gradle.kts +++ b/js/js.tests/build.gradle.kts @@ -226,6 +226,7 @@ val generateTypeScriptJsExportOnFileTests = sequential( !it.path.endsWith("implicit-export") && !it.path.endsWith("inheritance") && !it.path.endsWith("strict-implicit-export") && + !it.path.endsWith("private-primary-constructor") && !it.path.endsWith(exportFileDirPostfix) } .map { generateJsExportOnFileTestFor(it.name) } diff --git a/js/js.tests/tests-gen/org/jetbrains/kotlin/js/test/ir/IrJsTypeScriptExportTestGenerated.java b/js/js.tests/tests-gen/org/jetbrains/kotlin/js/test/ir/IrJsTypeScriptExportTestGenerated.java index 76136a0b800..1dbc2d32fe2 100644 --- a/js/js.tests/tests-gen/org/jetbrains/kotlin/js/test/ir/IrJsTypeScriptExportTestGenerated.java +++ b/js/js.tests/tests-gen/org/jetbrains/kotlin/js/test/ir/IrJsTypeScriptExportTestGenerated.java @@ -637,6 +637,22 @@ public class IrJsTypeScriptExportTestGenerated extends AbstractIrJsTypeScriptExp } } + @Nested + @TestMetadata("js/js.translator/testData/typescript-export/private-primary-constructor") + @TestDataPath("$PROJECT_ROOT") + public class Private_primary_constructor { + @Test + public void testAllFilesPresentInPrivate_primary_constructor() throws Exception { + KtTestUtil.assertAllTestsPresentByMetadataWithExcluded(this.getClass(), new File("js/js.translator/testData/typescript-export/private-primary-constructor"), Pattern.compile("^([^_](.+))\\.kt$"), null, TargetBackend.JS_IR, true); + } + + @Test + @TestMetadata("private-primary-constructor.kt") + public void testPrivate_primary_constructor() throws Exception { + runTest("js/js.translator/testData/typescript-export/private-primary-constructor/private-primary-constructor.kt"); + } + } + @Nested @TestMetadata("js/js.translator/testData/typescript-export/properties") @TestDataPath("$PROJECT_ROOT") diff --git a/js/js.translator/testData/typescript-export/private-primary-constructor/private-primary-constructor.d.ts b/js/js.translator/testData/typescript-export/private-primary-constructor/private-primary-constructor.d.ts new file mode 100644 index 00000000000..d2f71519e0d --- /dev/null +++ b/js/js.translator/testData/typescript-export/private-primary-constructor/private-primary-constructor.d.ts @@ -0,0 +1,25 @@ +declare namespace JS_TESTS { + type Nullable = T | null | undefined + namespace foo { + class ClassWithoutPrimary { + private constructor(); + get value(): string; + static fromInt(value: number): foo.ClassWithoutPrimary; + static fromString(value: string): foo.ClassWithoutPrimary; + } + /* @ts-ignore: extends class with private primary constructor */ + class SomeBaseClass extends foo.ClassWithoutPrimary { + private constructor(); + get answer(): number; + static secondary(): foo.SomeBaseClass; + } + /* @ts-ignore: extends class with private primary constructor */ + class SomeExtendingClass extends /* foo.IntermediateClass1 */ foo.SomeBaseClass { + private constructor(); + } + /* @ts-ignore: extends class with private primary constructor */ + class FinalClassInChain extends /* foo.IntermediateClass2 */ foo.SomeExtendingClass { + constructor(); + } + } +} \ No newline at end of file diff --git a/js/js.translator/testData/typescript-export/private-primary-constructor/private-primary-constructor.kt b/js/js.translator/testData/typescript-export/private-primary-constructor/private-primary-constructor.kt new file mode 100644 index 00000000000..55bc3331f22 --- /dev/null +++ b/js/js.translator/testData/typescript-export/private-primary-constructor/private-primary-constructor.kt @@ -0,0 +1,41 @@ +// KT-52563 +// CHECK_TYPESCRIPT_DECLARATIONS +// RUN_PLAIN_BOX_FUNCTION +// SKIP_MINIFICATION +// SKIP_NODE_JS +// INFER_MAIN_MODULE +// MODULE: JS_TESTS +// FILE: enum-classes.kt + +package foo + +@JsExport +open class ClassWithoutPrimary { + val value: String + + @JsName("fromInt") + constructor(value: Int) { + this.value = value.toString() + } + + @JsName("fromString") + constructor(value: String) { + this.value = value + } +} + +@JsExport +open class SomeBaseClass private constructor(val answer: Int): ClassWithoutPrimary(answer) { + @JsName("secondary") + constructor() : this(42) +} + +open class IntermediateClass1: SomeBaseClass() + +@JsExport +open class SomeExtendingClass @JsExport.Ignore public constructor() : IntermediateClass1() + +open class IntermediateClass2: SomeExtendingClass() + +@JsExport +class FinalClassInChain: IntermediateClass2() \ No newline at end of file diff --git a/js/js.translator/testData/typescript-export/private-primary-constructor/private-primary-constructor__main.js b/js/js.translator/testData/typescript-export/private-primary-constructor/private-primary-constructor__main.js new file mode 100644 index 00000000000..a2814b89faa --- /dev/null +++ b/js/js.translator/testData/typescript-export/private-primary-constructor/private-primary-constructor__main.js @@ -0,0 +1,20 @@ +"use strict"; +var SomeBaseClass = JS_TESTS.foo.SomeBaseClass; +var SomeExtendingClass = JS_TESTS.foo.SomeExtendingClass; +var FinalClassInChain = JS_TESTS.foo.FinalClassInChain; +function assert(condition) { + if (!condition) { + throw "Assertion failed"; + } +} +function box() { + // @ts-expect-error "the constructor is private and can't be used from JS/TS code" + var baseClass = new SomeBaseClass(4); + assert(baseClass.answer === 4); + // @ts-expect-error "the constructor is private and can't be used from JS/TS code" + var extendingClass = new SomeExtendingClass(); + assert(extendingClass.answer === 42); + var finalClassInChain = new FinalClassInChain(); + assert(finalClassInChain.answer === 42); + return "OK"; +} diff --git a/js/js.translator/testData/typescript-export/private-primary-constructor/private-primary-constructor__main.ts b/js/js.translator/testData/typescript-export/private-primary-constructor/private-primary-constructor__main.ts new file mode 100644 index 00000000000..bf6a85e259a --- /dev/null +++ b/js/js.translator/testData/typescript-export/private-primary-constructor/private-primary-constructor__main.ts @@ -0,0 +1,24 @@ +import SomeBaseClass = JS_TESTS.foo.SomeBaseClass; +import SomeExtendingClass = JS_TESTS.foo.SomeExtendingClass; +import FinalClassInChain = JS_TESTS.foo.FinalClassInChain; + +function assert(condition: boolean) { + if (!condition) { + throw "Assertion failed"; + } +} + +function box(): string { + // @ts-expect-error "the constructor is private and can't be used from JS/TS code" + const baseClass = new SomeBaseClass(4) + assert(baseClass.answer === 4) + + // @ts-expect-error "the constructor is private and can't be used from JS/TS code" + const extendingClass = new SomeExtendingClass() + assert(extendingClass.answer === 42) + + const finalClassInChain = new FinalClassInChain() + assert(finalClassInChain.answer === 42) + + return "OK"; +} \ No newline at end of file diff --git a/js/js.translator/testData/typescript-export/private-primary-constructor/tsconfig.json b/js/js.translator/testData/typescript-export/private-primary-constructor/tsconfig.json new file mode 100644 index 00000000000..17612c56c9d --- /dev/null +++ b/js/js.translator/testData/typescript-export/private-primary-constructor/tsconfig.json @@ -0,0 +1,4 @@ +{ + "include": [ "./*" ], + "extends": "../common.tsconfig.json" +} \ No newline at end of file