diff --git a/docs/SPEC.md b/docs/SPEC.md index 01344b8cc..13bc83c33 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -252,6 +252,15 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. static (`Arrangement.spacedBy`, #53), except `Any`'s members, ones that also exist as a `@JvmStatic` static, and `@Composable` ones (declined); an `internal` or file-private object is skipped. `Status: implemented` — `GP/artifact/ObjectMemberBindingTest.kt`. + A public constructor of a public, non-abstract, non-generic class binds under the class's own name + (`pkg.Class`), including one with a value-class parameter, which `kotlinc` compiles to a private + `` behind a public synthetic bridge ending in `DefaultConstructorMarker` (the descriptor + `@Metadata` records); a `@Deprecated(level = HIDDEN)` constructor, whose bridge carries that + annotation, is skipped (#73). A declared `kotlin.CharSequence` **result** — of a function, a property + getter or an object constant — crosses as a Python `str` (the generated body returns Kotlin's + `toString()` of it; `null` stays `None` for `CharSequence?`); a `CharSequence` parameter is still + declined (#73). `Status: implemented` — `GP/artifact/TextStateBindingTest.kt`, + `ksp-fixtures/compose/.../TextFieldStateRenderTest.kt`. - **B-2** The walker on **klibs** (Kotlin/Native libraries). `Status: partial` — `GP/artifact/KlibScannerTest.kt` and `ksp-fixtures/klib-artifact` assert that the scanned klib's declarations are declined with reasons; no klib declaration is bound at run time yet. @@ -290,7 +299,13 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. `GP/stubs/TypedStubTest.kt`, `GP/stubs/KotlinNamesOnlyStubTest.kt`, `ksp-fixtures/artifact/.../WalkedArtifactStubTest.kt`, `.../StubSignatureAgreesWithRuntimeTest.kt`, `tools/stubs/check-stubs.sh` (mypy over the Compose stubs). A class whose name is also a function in - its module is `Any`. A property is a `@property` (with a setter for a `var`) of its receiver's stub + its module is `Any` — except that a class's own bound constructor is that class's `__init__` + (`@overload`ed for several), so the class keeps its stub, members and properties; a single one's + docstring carries its table key after `Kotlin constructor: `; its `__`-suffixed table-key spellings + stay module functions; a value class bound as its primitive keeps its constructor as a module function + returning the primitive (#73, `GP/artifact/TextStateBindingTest.kt`, + `ksp-fixtures/artifact/.../WalkedArtifactStubTest.kt`). In Python the class name is the constructor, + as `Typography(...)` already was (`M3ProofRenderTest.kt`). A property is a `@property` (with a setter for a `var`) of its receiver's stub class, its docstring carrying its table key after `Kotlin property: `; where that class has no stub, a comment with the same marker says so (`GP/stubs/PropertyStubTest.kt`). An object constant is annotated with its declared type even when that type is nested in the object (`Alignment.End: Horizontal`, a class of the diff --git a/docs/roadmap/ROADMAP.md b/docs/roadmap/ROADMAP.md index 2e727d374..a92bc25de 100644 --- a/docs/roadmap/ROADMAP.md +++ b/docs/roadmap/ROADMAP.md @@ -366,8 +366,9 @@ description, §16e/§16f's open remainder is below. - **16.7 Compose through Python on Android, iOS and wasm — open.** SPEC B-6/N-2: the render proofs (`ksp-fixtures/compose/src/desktopTest/`) are desktop only. - **16.8 Two material3 components not reachable from Python — open.** The dynamic `ColorScheme` - factory is Android-only and the class name resolves to a proxy whose constructor wants a handle - (`M3ProofRenderTest.colorSchemeResolvesToItsProxyTypeRatherThanAnythingCallable`). `DropdownMenu` + factory is Android-only. Since #73 the class name resolves to `ColorScheme`'s own constructor (its + `Color` parameters no longer hide it), which needs every colour written out; nothing renders with one + yet (`M3ProofRenderTest.colorSchemeResolvesToItsConstructorAndABareCallNamesItsOverloads`). `DropdownMenu` renders into a popup layer `ImageComposeScene` does not capture, so it is unverified rather than known broken (`M3ProofRenderTest.popupLayersAreNotCapturedByImageComposeScene`). diff --git a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactStubTest.kt b/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactStubTest.kt index 461219005..4ed7735b2 100644 --- a/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactStubTest.kt +++ b/ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactStubTest.kt @@ -211,11 +211,24 @@ class WalkedArtifactStubTest { assertEquals(emptySet(), properties - propertyKeys, "a property stub names no property key") assertEquals(emptySet(), propertyKeys - properties, "a property key has no stub") + // A fourth shape (issue #73): a class's own constructor, whose key (`kotlin.text.MatchGroup`) is a + // module attribute at run time, is stubbed as that class's `__init__` so the class keeps its stub + // -- and its docstring carries the key after `Kotlin constructor: `. Matched by key, like a + // property, and counted as stubbed only in the direction "this key has a stub": a marker naming + // no installed key is still "stubbed but not callable" below. + val constructors = generatedPaths() + .filter { it.endsWith(".pyi") } + .flatMap { path -> + Regex("""Kotlin constructor: ([^\s"]+)""").findAll(stub(path)).map { it.groupValues[1] }.toList() + } + .toSet() + assertEquals(emptySet(), constructors - installed, "a constructor stub names no table key") + // The base name of an overload set (`padding` for `padding__Dp`, ...) is served by the binding // layer's dispatcher and is stubbed as `@overload`s; it is not itself a table key. val overloadBases = installed.map { it.substringBeforeLast('.') + "." + it.substringAfterLast('.').substringBefore("__") } .filter { it !in installed }.toSet() assertEquals(emptySet(), stubbed - installed - overloadBases, "stubbed but not callable") - assertEquals(emptySet(), installed - propertyKeys - stubbed, "callable but not stubbed") + assertEquals(emptySet(), installed - propertyKeys - stubbed - constructors, "callable but not stubbed") } } diff --git a/ksp-fixtures/compose/build.gradle.kts b/ksp-fixtures/compose/build.gradle.kts index 7141f92c2..9a111a31f 100644 --- a/ksp-fixtures/compose/build.gradle.kts +++ b/ksp-fixtures/compose/build.gradle.kts @@ -104,6 +104,13 @@ pythonBindings { "androidx.compose.runtime.SnapshotStateKt", "androidx.compose.runtime.State", "androidx.compose.runtime.MutableState", + // 7. `TextField(state = ...)`'s state (issue #73): the `TextFieldState` class (its constructor + // and its `text`) and its file facade (`rememberTextFieldState`, + // `setTextAndPlaceCursorAtEnd`, `clearText`). Classes, not the package, for item 6's + // reason: `androidx.compose.foundation.text.input` is the whole new text stack. + // `TextFieldStateRenderTest` is what each one is for. + "androidx.compose.foundation.text.input.TextFieldState", + "androidx.compose.foundation.text.input.TextFieldStateKt", ), ) // Stubs on: `generatePythonStubs` over the real Compose jars is what `tools/stubs/check-stubs.sh` (the mypy diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/M3ProofRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/M3ProofRenderTest.kt index 5a83f88b4..45967c713 100644 --- a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/M3ProofRenderTest.kt +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/M3ProofRenderTest.kt @@ -798,20 +798,25 @@ class M3ProofRenderTest { } /** - * `ColorScheme` is unreachable, and the message says which of the two reasons reaches Python - * first. + * `ColorScheme` resolves to its **constructor**, and calling it with nothing fails as a call of that + * constructor -- not as a proxy type asking for a handle. * - * The documented reason is arithmetic: the factory takes thirty-six colour parameters and the - * omission mask holds thirty-one bits, so it cannot be called with defaults. That is true of - * `lightColorScheme`. But the *name* `ColorScheme` resolves to the class, and a bound class is - * a proxy type whose constructor wants the handle of an existing Kotlin instance -- so calling - * it bare fails one step earlier than the parameter count, asking for `handle`. + * This pinned the other answer until issue #73, and said why it might change: "Pinned as observed + * rather than as expected. When this message changes, something about how classes bind changed with + * it." What changed is exactly that. `ColorScheme`'s public constructors take `Color`, a value class, + * so `kotlinc` compiles each to a private `` behind a synthetic `DefaultConstructorMarker` + * bridge, and the walker used to refuse every synthetic ``. It now refuses only a + * `@Deprecated(level = HIDDEN)` one, so `ColorScheme(...)` is bound -- the same way `Typography(...)` + * already was ([typographyChangesFontRender] calls it by its class name). * - * Pinned as observed rather than as expected. When this message changes, something about how - * classes bind changed with it. + * Why the constructor and not the type: in Kotlin, `ColorScheme(...)` *is* the constructor call, and + * the binder carries Kotlin's surface (U-8). The proxy type answers "build me from a handle", which no + * Python caller can use -- this test was the evidence -- and stays reachable as `type(value)` of + * anything Kotlin returns. `ColorScheme()` with no arguments still fails, because no constructor + * declares a default for its colours; the message now names the constructor's overloads. */ @Test - fun colorSchemeResolvesToItsProxyTypeRatherThanAnythingCallable() { + fun colorSchemeResolvesToItsConstructorAndABareCallNamesItsOverloads() { val error = try { Python3.exec("from androidx.compose.material3 import ColorScheme; ColorScheme()") null @@ -819,11 +824,12 @@ class M3ProofRenderTest { e.message ?: "" } println("compose render: ColorScheme() -> ${error?.take(120)}") - assertTrue(error != null, "ColorScheme() succeeded but nothing in the table can build one") + assertTrue(error != null, "ColorScheme() succeeded, but every ColorScheme constructor requires its colours") assertTrue( - error.contains("handle"), - "expected the proxy type's own constructor to ask for a handle, got: $error", + "ColorScheme__" in error, + "expected the bare call to be refused by ColorScheme's constructor overloads, got: $error", ) + assertTrue("handle" !in error, "ColorScheme still resolves to the proxy type: $error") } // ── Helpers ─────────────────────────────────────────────────────────────── diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/TextFieldStateRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/TextFieldStateRenderTest.kt new file mode 100644 index 000000000..15f74ec38 --- /dev/null +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/TextFieldStateRenderTest.kt @@ -0,0 +1,209 @@ +package fixture.compose + +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.unit.Density +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.Image +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * **`TextField(state = ...)` from Python** (issue #73): the state-based text field of Compose 1.11, the + * one pythonx-compose's `TextField` design uses so that typing stays inside Compose. + * + * Everything here is reached through the walk under Kotlin names, with no fixture function between + * Python and Compose: + * + * | Python | Kotlin | + * |---|---| + * | `TextFieldState()`, `TextFieldState('seed')` | the constructor `TextFieldState(initialText = "", initialSelection = TextRange(...))`, outside any composition | + * | `rememberTextFieldState('abc')` | the `@Composable` of that name, inside one | + * | `s.text` | the `CharSequence` property, read as a Python `str` | + * | `s.setTextAndPlaceCursorAtEnd(...)`, `s.clearText()` | the two extension functions, as methods of the state's proxy | + * | `TextField(state=s)` | material3's state overload | + * + * ### Red before the fix + * + * `from androidx.compose.foundation.text.input import TextFieldState` (or `rememberTextFieldState`) + * finds no binding: the fixture did not walk the package, and once it does, the walker dropped the + * constructor (a value-class parameter compiles to a synthetic bridge) and declined `text` + * (`kotlin.CharSequence` had no boundary type). Each test therefore fails at its first `exec` with an + * `ImportError`/`AttributeError`/`TypeError` from Python -- never at a pixel assertion, which is what + * tells that red from a rendering regression. + */ +class TextFieldStateRenderTest { + + @BeforeTest + fun installProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + } + + @AfterTest + fun cleanup() { + Python3.exec( + """ + for _name in [n for n in globals() if n.startswith('_tfs')]: + del globals()[_name] + """.trimIndent(), + ) + UpcallTable.clear() + } + + /** + * Made outside a composition, both ways (all defaults left out, and `initialText` given), read back + * as `str`, written through the two extensions -- and drawn: the same `TextField(state=...)` body + * over the empty state and over the written one gives different pixels. + */ + @Test + fun aStateMadeOutsideACompositionIsReadAsStrWrittenByItsExtensionsAndDrawn() { + Python3.exec( + """ + from androidx.compose.foundation.text.input import TextFieldState + + _tfs_empty = TextFieldState() + assert type(_tfs_empty)._kotlin_type_name == 'androidx.compose.foundation.text.input.TextFieldState', \ + type(_tfs_empty)._kotlin_type_name + assert type(_tfs_empty.text) is str, type(_tfs_empty.text) + assert _tfs_empty.text == '', repr(_tfs_empty.text) + + _tfs_seeded = TextFieldState('seed') + assert _tfs_seeded.text == 'seed', repr(_tfs_seeded.text) + _tfs_seeded.clearText() + assert _tfs_seeded.text == '', repr(_tfs_seeded.text) + + _tfs = TextFieldState() + _tfs.setTextAndPlaceCursorAtEnd('hello from python') + assert type(_tfs.text) is str and _tfs.text == 'hello from python', repr(_tfs.text) + """.trimIndent(), + ) + + val empty = pixelsOf(textFieldOver("_tfs_empty")) + val written = pixelsOf(textFieldOver("_tfs")) + val moved = differing(empty, written) + println("compose TextFieldState: ink empty ${inkOf(empty)} -> written ${inkOf(written)} px, $moved pixels differ") + assertTrue(inkOf(empty) > 0, "TextField(state=...) never composed") + assertTrue(moved > 10, "only $moved pixels differ, so the text written into the state never reached the field") + } + + /** + * The `initialSelection` slot: `TextRange` is a value class whose constructor is `internal`, so it + * crosses as a handle made by its walked factory `TextRange(index: Int)` -- separate from the test + * above so a failure here says it is this slot. + * + * Spelled by its table key, `TextRange__Int`. The bare name `TextRange` is not the factory's overload + * set in Python: `TextRange.Zero` (a companion constant) makes `androidx.compose.ui.text.TextRange` a + * module, and that module is what `from ... import TextRange` finds -- observed as + * "'_PmModule_androidx_compose_ui_text_TextRange' object is not callable". That collision predates + * issue #73 and is not this test's subject. + */ + @Test + fun theInitialSelectionCrossesAsATextRangeHandle() { + Python3.exec( + """ + from androidx.compose.foundation.text.input import TextFieldState + from androidx.compose.ui.text import TextRange__Int + + _tfs_selected = TextFieldState('hello', TextRange__Int(2)) + assert _tfs_selected.text == 'hello', repr(_tfs_selected.text) + _tfs_kw = TextFieldState(initialText='kw', initialSelection=TextRange__Int(0)) + assert _tfs_kw.text == 'kw', repr(_tfs_kw.text) + """.trimIndent(), + ) + } + + /** + * Made inside a composition by `rememberTextFieldState`, drawn by `TextField(state=...)` in the same + * body, then written from Python **outside** the composition: the state is Compose snapshot state, + * so the next frame of the *same* scene shows the new text with no host call and no Python body + * re-run being needed. Every value the body saw is the one remembered state (each reads the written + * text). + */ + @Test + fun aRememberedStateIsDrawnAndAWriteFromPythonShowsOnTheNextFrame() { + Python3.exec("_tfs_seen = []") + val scene = ImageComposeScene(width = FIELD_WIDTH, height = FIELD_HEIGHT, density = Density(1f)) { + PythonComposition(REMEMBERED) + } + val before: IntArray + val after: IntArray + try { + before = pixelsOf(scene.render()) + Python3.exec( + """ + assert len(_tfs_seen) >= 1, _tfs_seen + assert type(_tfs_seen[0].text) is str and _tfs_seen[0].text == 'abc', repr(_tfs_seen[0].text) + _tfs_seen[0].setTextAndPlaceCursorAtEnd('a much longer line of text') + """.trimIndent(), + ) + Snapshot.sendApplyNotifications() + after = pixelsOf(scene.render()) + Python3.exec( + """ + assert all(_s.text == 'a much longer line of text' for _s in _tfs_seen), \ + [_s.text for _s in _tfs_seen] + """.trimIndent(), + ) + } finally { + scene.close() + } + val moved = differing(before, after) + println("compose rememberTextFieldState: ink ${inkOf(before)} -> ${inkOf(after)} px, $moved pixels differ") + assertTrue(inkOf(before) > 0, "TextField(state=rememberTextFieldState(...)) never composed") + assertTrue(moved > 10, "only $moved pixels differ, so the write from Python never reached the next frame") + } + + private fun textFieldOver(stateName: String): String = """ + from androidx.compose.material3 import TextField + TextField(state=$stateName) + """.trimIndent() + + private fun pixelsOf(body: String): IntArray { + val scene = ImageComposeScene(width = FIELD_WIDTH, height = FIELD_HEIGHT, density = Density(1f)) { + PythonComposition(body) + } + try { + return pixelsOf(scene.render()) + } finally { + scene.close() + } + } + + private fun pixelsOf(image: Image): IntArray { + val bitmap = Bitmap.makeFromImage(image) + return IntArray(bitmap.width * bitmap.height) { bitmap.getColor(it % bitmap.width, it / bitmap.width) } + } + + private fun inkOf(pixels: IntArray): Int = pixels.count { it != 0 } + + private fun differing(a: IntArray, b: IntArray): Int { + assertEquals(a.size, b.size) + return a.indices.count { a[it] != b[it] } + } + + private companion object { + /** Wide enough for material3's `TextField` and a line of text at density 1. */ + const val FIELD_WIDTH = 280 + const val FIELD_HEIGHT = 64 + + val REMEMBERED = """ + from androidx.compose.foundation.text.input import rememberTextFieldState + from androidx.compose.material3 import TextField + _tfs_state = rememberTextFieldState('abc') + _tfs_seen.append(_tfs_state) + TextField(state=_tfs_state) + """.trimIndent() + } +} diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactScanner.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactScanner.kt index caeb082e2..712a38a99 100644 --- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactScanner.kt +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactScanner.kt @@ -1223,7 +1223,10 @@ internal object ArtifactScanner { // value classes to `androidx.compose.ui.input.pointer.PointerInputChange`, whose old // `consumed: ConsumedData` constructor is exactly this shape: `e: None of the following // candidates is applicable` against a generated call naming a constructor Kotlin source can - // no longer resolve. + // no longer resolve. Issue #73 narrowed this: a constructor with a value-class parameter has + // the same synthetic-bridge shape *without* being hidden (`TextFieldState`), and only the + // `kotlin.Deprecated(level = HIDDEN)` on the bridge tells the two apart -- see + // [realPublicInitDescriptorsOf]. // // Checked for an **ordinary class only**. A value class's own box constructor is the other // case this object's KDoc already warns bytecode cannot judge -- "the compiler makes the @@ -1237,15 +1240,57 @@ internal object ArtifactScanner { .filter { kmClass.isValue || it.jvmSignature.descriptor in realPublicInitDescriptorsOf(ownerNode) } .mapNotNull { function -> val (paramDescriptors, _) = splitMethodDescriptor(function.jvmSignature.descriptor) - candidateFromFunction(owner, false, function, classpath, paramDescriptors, isComposable = false)?.copy(isConstructor = true) + candidateFromFunction(owner, false, function, classpath, paramDescriptors, isComposable = false) + ?.let { it.copy(isConstructor = true, declaration = it.declaration.copy(isConstructor = true)) } } } + /** + * The JVM descriptors of the ``s Kotlin source outside the module can reach. + * + * ### Two synthetic shapes, and only one of them is hidden (issue #73) + * + * A constructor with a **value-class parameter** compiles to a JVM-`private` `` taking the + * unboxed values, behind a public `ACC_SYNTHETIC` bridge whose last parameter is a + * `DefaultConstructorMarker` -- and that bridge is the descriptor `@Metadata` records. `javap -v` on + * `androidx/compose/foundation/text/input/TextFieldState` (foundation-desktop 1.11.1): the metadata + * constructor `(initialText: String, initialSelection: TextRange)` is + * `(Ljava/lang/String;JLkotlin/jvm/internal/DefaultConstructorMarker;)V`, `ACC_PUBLIC, ACC_SYNTHETIC`, + * no annotation. Rejecting every synthetic `` therefore dropped `TextFieldState`'s only public + * constructor -- silently, so its stub class came out with no way to create one. + * + * A `@Deprecated(level = HIDDEN)` constructor with a value-class parameter has **the same** shape, + * and is told apart only by the annotation the compiler leaves on the bridge: + * `PointerInputChange`'s `(…, ConsumedData, …, DefaultConstructorMarker)` and four of `TextStyle`'s + * bridges carry `RuntimeVisibleAnnotations: kotlin.Deprecated(level = DeprecationLevel.HIDDEN)`, + * while their non-hidden siblings carry none. Source cannot call a hidden one at all ("Unresolved + * reference"), so it stays out -- without a model, like every other hidden declaration + * ([kotlinCandidates]). A plain synthetic `` without the marker (an ordinary hidden + * constructor) stays out as before. + */ private fun realPublicInitDescriptorsOf(ownerNode: ClassNode): Set = ownerNode.methods - .filter { it.name == "" && it.access.hasFlag(Opcodes.ACC_PUBLIC) && !it.access.hasFlag(Opcodes.ACC_SYNTHETIC) } + .filter { it.name == "" && it.access.hasFlag(Opcodes.ACC_PUBLIC) } + .filter { method -> + !method.access.hasFlag(Opcodes.ACC_SYNTHETIC) || + (method.desc.endsWith(DEFAULT_CONSTRUCTOR_MARKER_TAIL) && !isHiddenDeprecated(method)) + } .mapTo(mutableSetOf()) { it.desc } + /** How a value-class constructor's public bridge ends; see [realPublicInitDescriptorsOf]. */ + private const val DEFAULT_CONSTRUCTOR_MARKER_TAIL = "Lkotlin/jvm/internal/DefaultConstructorMarker;)V" + + /** `kotlin.Deprecated` is `RUNTIME`-retained, so a `level = HIDDEN` on a bridge is in the visible list. */ + private fun isHiddenDeprecated(method: org.objectweb.asm.tree.MethodNode): Boolean = + method.visibleAnnotations.orEmpty().any { annotation -> + if (annotation.desc != "Lkotlin/Deprecated;") return@any false + val values = annotation.values ?: return@any false + (0 until values.size - 1 step 2).any { i -> + values[i] == "level" && + (values[i + 1] as? Array<*>)?.let { it.getOrNull(0) == "Lkotlin/DeprecationLevel;" && it.getOrNull(1) == "HIDDEN" } == true + } + } + /** * Whether this JVM method is a `@Composable`. * diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KotlinMetadata.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KotlinMetadata.kt index 40e362cab..961fcf371 100644 --- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KotlinMetadata.kt +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/KotlinMetadata.kt @@ -239,6 +239,16 @@ internal fun resolveKotlinBoundary( direction: BoundaryDirection, ): ResolvedBoundary? { val classifier = type.classifier as? KmClassifier.Class ?: return null + // Issue #73: a declared `kotlin.CharSequence` **result** crosses as a Python `str`. Kotlin's own + // `toString()` of a `CharSequence` is its characters (`TextFieldState.text` is a + // `TextFieldCharSequence`, a `StringBuilder` is one too), so the generated body converts and the + // boundary carries the `STRING` it already knows. Return only: a `CharSequence` *parameter* stays + // declined, because nothing has asked for it and `kotlin/CharSequence` has no class file to cast + // an object handle to. + if (classifier.name == KOTLIN_CHAR_SEQUENCE) { + if (direction != BoundaryDirection.RETURN) return null + return ResolvedBoundary(if (type.isNullable) NULLABLE_CHAR_SEQUENCE_RESULT else CHAR_SEQUENCE_RESULT, carrier = null) + } // A nullable *primitive* or value class stays declined. `TypeTag.INT` carries a `Long` and the // read narrows it (`(args[0] as Long).toInt()`), so a `null` arriving for an `Int?` would throw // inside the cast rather than reach the declaration -- and a nullable value class boxes, which @@ -499,6 +509,29 @@ private fun renderKotlinTypeName(type: KmType, classpath: ArtifactClasspath, isA /** `kotlin.Any`, as `KmClassifier.Class` spells it. */ internal const val KOTLIN_ANY = "kotlin/Any" +/** `kotlin.CharSequence`, as `KmClassifier.Class` spells it. A built-in: it has no class file. */ +internal const val KOTLIN_CHAR_SEQUENCE = "kotlin/CharSequence" + +/** + * What a declared `kotlin.CharSequence` result crosses as (issue #73): the `str` of its characters. + * Return-only -- [resolveKotlinBoundary] never hands it out for a parameter, and its read refuses to be + * written into generated source should anything ever ask. + */ +private val CHAR_SEQUENCE_RESULT = BoundaryType( + tag = "STRING", + readFn = { error("a kotlin.CharSequence boundary is return-only") }, + wrapFn = { call -> "($call).toString()" }, + isReturnOnly = true, +) + +/** The nullable form: `null` stays `null`, which the `STRING` tag already carries as `None`. */ +private val NULLABLE_CHAR_SEQUENCE_RESULT = BoundaryType( + tag = "STRING", + readFn = { error("a kotlin.CharSequence boundary is return-only") }, + wrapFn = { call -> "($call)?.toString()" }, + isReturnOnly = true, +) + /** What a `kotlin.Any?` slot crosses as: an object handle, cast to nothing narrower. */ private val NULLABLE_ANY_BOUNDARY = BoundaryType("OBJECT", "(%s as kotlin.Any?)", "(%s)") diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/model/StubModel.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/model/StubModel.kt index 14fbbbe47..8c1db58ed 100644 --- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/model/StubModel.kt +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/model/StubModel.kt @@ -95,6 +95,14 @@ internal data class DeclarationModel( * gap to fill. */ val returnSupertypes: List = emptyList(), + /** + * The declaration is a class's own constructor (issue #73): [simpleName] is the class's name, + * [owner] its package and [returnType] the class itself. A stub renders it as that class's + * `__init__` rather than as a module function that would take the class's name away from it -- a + * top-level factory of the same name (`PaddingValues(...)`) is a different declaration and keeps + * the `Any` rule. + */ + val isConstructor: Boolean = false, ) : Serializable /** @param name `null` when the producer could not read one (§3.2); never a synthesised `arg0`. */ diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PyiRendering.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PyiRendering.kt index d17f6aa47..5acd28555 100644 --- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PyiRendering.kt +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/stubs/PyiRendering.kt @@ -31,7 +31,13 @@ import python.multiplatform.gradle.model.ValueClassModel * * A class whose name is also a function in its module (`PaddingValues(...)` the factory and * `PaddingValues` the interface) cannot be both in one Python module, and the runtime resolves the - * name to the function, so such a type is annotated `Any`. + * name to the function, so such a type is annotated `Any`. A class's **own constructor** is the + * exception (issue #73): it is rendered as the class's `__init__`, so the class keeps its stub -- + * members, properties and all -- and `TextFieldState(...)` type-checks as a `TextFieldState`, which is + * what the runtime's constructor callable returns; its docstring carries the table key after + * [CONSTRUCTOR_MARKER]. Its `__`-suffixed table-key spellings stay module functions as well. A value + * class bound as its primitive is not the exception: its constructor returns the raw number. (`isinstance(x, TextFieldState)` is the one thing the stub promises that the + * runtime, whose module attribute is the callable, does not do.) * * A Kotlin `object` (`Alignment`, `Arrangement`) is a module of its constants and functions, and the * types nested in it (`Alignment.Horizontal`) are classes of that same module, so @@ -80,6 +86,15 @@ internal fun renderKotlinFqnStubs(declarations: List): Map) { private val byModule: Map> = bound.groupBy { moduleOf(it) }.toSortedMap() - /** Every name a module defines at the top level, which a class stub must not take. */ + /** + * A class's own constructor, published in the class's own package under the class's name (issue + * #73). It does not take that name away from the class: the stub writes it as the class's + * `__init__`, so `TextFieldState(...)` type-checks as making a `TextFieldState` -- which is what the + * runtime's constructor callable returns. A factory function of the same name is not one of these. + * + * Only a constructor whose result crosses as a **handle**: a value class the boundary opens + * (`Meters`, `Dp`) is built and returned as its raw primitive, so `Meters(3.0)` gives a `float`, and + * an `__init__` would promise a `Meters`. That one stays a module function and its class `Any`, as + * before issue #73. + */ + private fun isOwnConstructor(d: DeclarationModel): Boolean = + d.isConstructor && d.kind == "FUNCTION" && d.receiver == null && d.returnBoundaryTag == "OBJECT" && + moduleOf(d) == d.owner && baseOf(leafOf(d)) == d.simpleName && + d.returnType.qualifiedName == "${d.owner}.${d.simpleName}" + + /** Every name a module defines at the top level, which a class stub must not take. A constructor's + * base name is its class's, so it takes nothing but its `__`-suffixed table-key spelling. */ private val takenNames: Map> = byModule.mapValues { (_, entries) -> - entries.flatMap { d -> leafOf(d).let { leaf -> if (isSuffixed(leaf)) listOf(leaf, baseOf(leaf)) else listOf(leaf) } }.toSet() + entries.flatMap { d -> + leafOf(d).let { leaf -> + when { + isOwnConstructor(d) -> if (isSuffixed(leaf)) listOf(leaf) else emptyList() + isSuffixed(leaf) -> listOf(leaf, baseOf(leaf)) + else -> listOf(leaf) + } + } + }.toSet() } + /** Class qualified name -> its bound constructors, in table-key order; filled by [renderModuleDefs]. */ + private val constructorsByClass = sortedMapOf>() + /** * Module prefixes (every module and every directory above it) that name one directory on a * case-insensitive filesystem together with a sibling (issue #44). In each such group the @@ -399,8 +442,16 @@ private class StubRenderer(declarations: List) { return@forEach } val ctx = out(module) - val sorted = entries.sortedBy { it.bindingName } + // A constructor is its class's `__init__` (see [isOwnConstructor]) whenever the class has a + // stub to put it in; only its `__`-suffixed table-key spelling is also a module function, + // because the runtime serves it as one. A class with no stub keeps the old rendering. + val (constructors, sorted) = entries.sortedBy { it.bindingName } + .partition { isOwnConstructor(it) && classReference(it.returnType, ctx) != ANY } sorted.forEach { ctx.defs += renderDef(it, ctx) } + constructors.forEach { d -> + if (isSuffixed(leafOf(d))) ctx.defs += renderDef(d, ctx) + constructorsByClass.getOrPut(d.returnType.qualifiedName) { mutableListOf() } += d + } // The base name of an overload set: the binding layer serves it, so the stub says it. val unsuffixedLeaves = sorted.filter { !isSuffixed(leafOf(it)) }.map { leafOf(it) }.toSet() sorted.filter { it.kind != "STATIC_GETTER" && isSuffixed(leafOf(it)) } @@ -474,6 +525,18 @@ private class StubRenderer(declarations: List) { if (reference != ANY) node.bases = reference } + constructorsByClass[qualifiedName]?.let { group -> + group.forEachIndexed { index, d -> + node.members += if (group.size == 1) { + "def __init__(${parameters(d, ctx, method = true)}) -> None:\n" + + " \"\"\"$CONSTRUCTOR_MARKER${d.bindingName} (${kotlinSignatureOf(d)})\"\"\"\n" + + " ..." + } else { + "@$TYPING.overload\ndef __init__(${parameters(d, ctx, method = true)}) -> None: ..." + shadowed(index) + } + } + } + val attributes = sortedMapOf>() extensionsByReceiver[qualifiedName].orEmpty().sortedBy { it.bindingName }.forEach { d -> val leaf = leafOf(d) diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ArtifactScannerTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ArtifactScannerTest.kt index 35967f208..b89dffd3f 100644 --- a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ArtifactScannerTest.kt +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ArtifactScannerTest.kt @@ -685,13 +685,13 @@ class ArtifactScannerTest { * name list that would have needed one more line per class someone next wants opened -- * [aFocusRequesterConstructorIsDroppedBecauseItWouldShadowFocusRequesterDefault] pins that rule * directly. `TextStyle` itself is pinned here only by outcome (no bound constructor, `Default` - * still reachable): in the jar this measures, every one of `TextStyle`'s own constructors turns - * out to be `@Deprecated(level = HIDDEN)` -- backed by a JVM-`private` implementation behind a - * synthetic public bridge metadata's descriptor does not match -- so - * [ArtifactScanner.constructorCandidates]'s separate bytecode cross-check (found by this same - * widening, against `PointerInputChange`) already filters every one of them out before - * [ArtifactScanner.dropCollidingConstructors] would have had a candidate to drop. Both checks - * close `TextStyle`; only the second is what this test can attribute to a specific mechanism. + * still reachable). Every public `TextStyle` constructor takes a value class, so each one is a + * JVM-`private` `` behind a synthetic `DefaultConstructorMarker` bridge; `javap -v` on + * ui-text-desktop 1.11.1 shows four of those bridges carrying `kotlin.Deprecated(level = HIDDEN)` + * and two that do not. Until issue #73 the bytecode cross-check in + * [ArtifactScanner.constructorCandidates] refused every synthetic ``, so all six were gone + * before [ArtifactScanner.dropCollidingConstructors] saw them; since #73 it refuses only the hidden + * four, and the other two reach -- and are dropped by -- the constant-collision rule. */ @Test fun aTypographyOrShapesConstructorBindsButATextStyleConstructorDoesNot() { diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/TextStateBindingTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/TextStateBindingTest.kt new file mode 100644 index 000000000..70a4f49d0 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/TextStateBindingTest.kt @@ -0,0 +1,190 @@ +package python.multiplatform.gradle.artifact + +import python.multiplatform.gradle.stubs.renderKotlinFqnStubs +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Issue #73 over `TextStateFixtures.kt`: why `androidx.compose.foundation.text.input.TextFieldState` was + * stubbed as a class with no members and no way to make one, reduced to the two walker rules that + * caused it. + * + * 1. **A constructor with a value-class parameter was dropped without a word.** `kotlinc` compiles it to + * a JVM-`private` `` behind a public `ACC_SYNTHETIC` bridge ending in + * `DefaultConstructorMarker`, and that bridge is the descriptor `@Metadata` records. The walker's + * check for `@Deprecated(level = HIDDEN)` constructors refused every synthetic ``, so it + * refused this one too -- with no model, so nothing said so. `javap -v` on foundation-desktop + * 1.11.1's `TextFieldState`: `(Ljava/lang/String;JLkotlin/jvm/internal/DefaultConstructorMarker;)V`, + * `ACC_PUBLIC, ACC_SYNTHETIC`, no annotation. + * 2. **A `CharSequence` result had no boundary type.** `kotlin.CharSequence` is a built-in with no class + * file, so the object-handle case could not name it and `TextFieldState.text` was declined + * ("no boundary type for property type kotlin.CharSequence"). + * + * (The third cause, that `:ksp-fixtures:compose` did not walk `androidx.compose.foundation.text.input` + * at all, is configuration, and `TextFieldStateRenderTest` there is its test.) + * + * ### Red before the implementation + * + * Red: [aConstructorWithAValueClassParameterBinds], [aHiddenValueClassConstructorStaysOutAndItsVisibleSiblingKeepsTheBareName] + * (no `Legacy` entry at all), [aCharSequencePropertyIsReadAsAStr], [aCharSequenceFunctionResultIsAStr] and + * [theStubGivesTheClassItsConstructorAndItsTextProperty] -- each `entry(...)` fails with "is not + * bound". Already green before, and kept as guards: [theExtensionsOnTheStateBindAsMethodsOfIt], + * [aCharSequenceParameterIsStillDeclined], and the `Plain` half of the hidden-constructor test. + */ +class TextStateBindingTest { + + private val pkg = "fixture.artifacttextstate" + + private val fixtureClasses: File + get() { + val location = Class.forName("$pkg.Editable").protectionDomain.codeSource.location + return File(location.toURI()).also { assertTrue(it.isDirectory, "expected a directory of .class files: $it") } + } + + private val entries by lazy { ArtifactScanner.scanJar(fixtureClasses, includePrefixes = listOf(pkg)) } + + private val declarations by lazy { ArtifactScanner.scanDeclarations(fixtureClasses, includePrefixes = listOf(pkg)) } + + private fun entry(name: String): ArtifactCallable = + assertNotNull(entries.singleOrNull { it.name == name }, "$name is not bound; bound: ${entries.map { it.name }}") + + // ------------------------------------------------------------------------ the constructor + + /** `TextFieldState(initialText = "", initialSelection = TextRange(...))`'s shape. */ + @Test + fun aConstructorWithAValueClassParameterBinds() { + val editable = entry("$pkg.Editable") + assertEquals(2, editable.arity) + assertEquals(listOf("initialText", "initialSelection"), editable.paramNames) + assertEquals(listOf("kotlin.String", "$pkg.Span"), editable.paramTypeNames) + // `Span` cannot be built from a number (its constructor is internal): it crosses as a handle. + assertEquals(listOf("STRING", "OBJECT"), editable.paramTags) + assertEquals("OBJECT", editable.returnTag) + assertEquals("$pkg.Editable", editable.returnTypeName) + // Both defaults are Python's to leave out (`TextFieldState()` from Python). + assertEquals(listOf(true, true), editable.paramHasDefault) + // Kotlin source calling the constructor by its Kotlin name -- never the bridge, never ``. + assertTrue("$pkg.Editable((args[0] as String), (args[1] as $pkg.Span))" in editable.lambdaBody, editable.lambdaBody) + assertFalse("DefaultConstructorMarker" in editable.lambdaBody, editable.lambdaBody) + + val model = declarations.single { it.owner == pkg && it.simpleName == "Editable" } + assertEquals("$pkg.Editable", model.bindingName, model.toString()) + assertNull(model.declineReason, model.toString()) + } + + /** + * `PointerInputChange`'s and `TextStyle`'s shape. The hidden constructor's bridge carries + * `kotlin.Deprecated(level = HIDDEN)` and must stay out -- if it did not, the two would share a name + * and both would be suffixed (`Legacy__Span`, `Legacy__Span_Boolean`), and the second would not + * compile. `Plain` is the hidden constructor with no bridge, out before and after. + */ + @Test + fun aHiddenValueClassConstructorStaysOutAndItsVisibleSiblingKeepsTheBareName() { + val names = entries.map { it.name } + assertEquals(1, entry("$pkg.Legacy").arity) + assertTrue(names.none { it.startsWith("$pkg.Legacy__") }, names.toString()) + assertEquals(1, entry("$pkg.Plain").arity) + assertTrue(names.none { it.startsWith("$pkg.Plain__") }, names.toString()) + } + + // ---------------------------------------------------------------------- CharSequence results + + /** `TextFieldState.text`: read as Kotlin's own `toString()` of it, which crosses as `str`. */ + @Test + fun aCharSequencePropertyIsReadAsAStr() { + val text = entry("$pkg.Editable.text") + assertEquals("GETTER", text.kind) + assertEquals("STRING", text.returnTag) + assertEquals("kotlin.CharSequence", text.returnTypeName) + assertEquals("{ args -> ((args[0] as $pkg.Editable).text).toString() }", text.lambdaBody) + + val draft = entry("$pkg.Editable.draft") + assertEquals("STRING", draft.returnTag) + assertEquals("{ args -> ((args[0] as $pkg.Editable).draft)?.toString() }", draft.lambdaBody, "null must stay null") + assertTrue(entries.none { it.name == "$pkg.Editable.text=" }, "a val has no setter") + } + + @Test + fun aCharSequenceFunctionResultIsAStr() { + val snapshot = entry("$pkg.snapshot") + assertEquals("STRING", snapshot.returnTag) + assertEquals("kotlin.CharSequence", snapshot.returnTypeName) + assertEquals( + "{ args -> ((args[0] as $pkg.Editable).artifact_ext_fixture_artifacttextstate_snapshot()).toString() }", + snapshot.lambdaBody, + ) + } + + /** Only a declared *result* is converted (issue #73's scope): a parameter has no class to cast to. */ + @Test + fun aCharSequenceParameterIsStillDeclined() { + assertTrue(entries.none { it.name == "$pkg.lengthOf" }, entries.map { it.name }.toString()) + val lengthOf = declarations.single { it.simpleName == "lengthOf" } + assertNull(lengthOf.bindingName) + val reason = assertNotNull(lengthOf.declineReason) + assertTrue("kotlin.CharSequence" in reason, reason) + } + + // ------------------------------------------------------------------------- the extensions + + /** `setTextAndPlaceCursorAtEnd(String)` and `clearText()`'s shapes: methods of the state's proxy. */ + @Test + fun theExtensionsOnTheStateBindAsMethodsOfIt() { + val replaceAll = entry("$pkg.replaceAll") + assertEquals("$pkg.Editable", replaceAll.receiverTypeName) + assertEquals(listOf("OBJECT", "STRING"), replaceAll.paramTags) + assertEquals("$pkg.Editable", entry("$pkg.clear").receiverTypeName) + } + + // -------------------------------------------------------------------------------- the stub + + /** + * What pythonx-compose reads: the class keeps its stub, with the constructor as its `__init__` and + * `text` as a `str` property, instead of a module function `Editable(...) -> Any` that takes the + * class's name and leaves its properties a comment. + */ + @Test + fun theStubGivesTheClassItsConstructorAndItsTextProperty() { + entry("$pkg.Editable") + val stub = renderKotlinFqnStubs(declarations).getValue("fixture/artifacttextstate/__init__.pyi") + assertTrue("class Editable:" in stub, stub) + assertTrue(" def __init__(self, initialText: str = ..., initialSelection: Span = ...) -> None:" in stub, stub) + assertTrue(" @property\n def text(self) -> str:" in stub, stub) + assertTrue(" @property\n def draft(self) -> str | None:" in stub, stub) + assertTrue(" replaceAll: _t.ClassVar[" in stub, stub) + assertFalse(Regex("^def Editable\\(", RegexOption.MULTILINE).containsMatchIn(stub), "the constructor took the class's name: $stub") + assertTrue("class Legacy:" in stub && " def __init__(self, span: Span) -> None:" in stub, stub) + // The table key `fixture.artifacttextstate.Editable` has no module-level `def`; the marker is where + // `WalkedArtifactStubTest`'s "every table key is stubbed" finds it. + assertTrue("\"\"\"Kotlin constructor: $pkg.Editable (" in stub, stub) + } + + /** + * A value class the boundary opens (`Meters(value: Double)`, bound as `FLOAT`) is built and returned + * as its raw number, so its constructor must stay a module function returning that number: an + * `__init__` would promise a `Meters` instance the runtime never gives. Found by + * `:ksp-fixtures:artifact`'s `everyStubbedFunctionIsATableKeyAndEveryTableKeyIsStubbed` reporting + * `fixture.valueclass.Meters` as "callable but not stubbed" after the first version of this change. + */ + @Test + fun aValueClassConstructorThatReturnsItsPrimitiveStaysAModuleFunction() { + val double = python.multiplatform.gradle.model.KotlinTypeModel("kotlin.Double") + val meters = python.multiplatform.gradle.model.KotlinTypeModel( + "fixture.vc.Meters", + valueClass = python.multiplatform.gradle.model.ValueClassModel(double, constructorIsPublic = true, propertyIsPublic = true), + ) + val constructor = python.multiplatform.gradle.model.DeclarationModel( + simpleName = "Meters", owner = "fixture.vc", ownerIsClass = false, receiver = null, + parameters = listOf(python.multiplatform.gradle.model.DeclaredParameter("value", double, declaresDefault = false, boundaryTag = "FLOAT")), + returnType = meters, returnBoundaryTag = "FLOAT", bindingName = "fixture.vc.Meters", isConstructor = true, + ) + val stub = renderKotlinFqnStubs(listOf(constructor)).getValue("fixture/vc/__init__.pyi") + assertTrue(Regex("^def Meters\\(value: float\\) -> float:", RegexOption.MULTILINE).containsMatchIn(stub), stub) + assertFalse("__init__" in stub, stub) + } +} diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/textstatefixture/TextStateFixtures.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/textstatefixture/TextStateFixtures.kt new file mode 100644 index 000000000..fe6509729 --- /dev/null +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/textstatefixture/TextStateFixtures.kt @@ -0,0 +1,67 @@ +// Fixture, not production source: compiled with this module's tests so `TextStateBindingTest` can walk +// it as bytecode plus `@Metadata`, the same way `PropertyFixtures.kt` is walked (issue #73). +@file:Suppress("unused", "UNUSED_PARAMETER") + +package fixture.artifacttextstate + +/** + * `androidx.compose.ui.text.TextRange`'s shape: a value class whose constructor is `internal` and whose + * wrapped value is private, so it can neither be built from a raw number nor unwrapped and crosses as + * an object handle. + */ +@JvmInline +value class Span internal constructor(private val packed: Long) + +/** `TextRange(index: Int)`'s shape: the one way outside code makes a [Span]. */ +fun spanAt(index: Int): Span = Span(index.toLong()) + +/** + * `androidx.compose.foundation.text.input.TextFieldState`'s shape: a public constructor whose second + * parameter is a value class and both of whose parameters declare a default, and a `text` read as + * `CharSequence`. + * + * The value-class parameter is the point: `kotlinc` compiles this constructor to a JVM-`private` + * `(String, long)` behind a public **synthetic** `(String, long, DefaultConstructorMarker)`, + * and the bridge is the descriptor `@Metadata` records. + */ +class Editable(initialText: String = "", initialSelection: Span = Span(initialText.length.toLong())) { + internal val buffer = StringBuilder(initialText) + + val text: CharSequence get() = buffer + + /** A nullable `CharSequence`: `null` while empty. */ + val draft: CharSequence? get() = if (buffer.isEmpty()) null else buffer +} + +/** `TextFieldState.setTextAndPlaceCursorAtEnd(String)`'s shape. */ +fun Editable.replaceAll(text: String) { + buffer.setLength(0) + buffer.append(text) +} + +/** `TextFieldState.clearText()`'s shape. */ +fun Editable.clear() { + buffer.setLength(0) +} + +/** A `CharSequence` returned by a function rather than read off a property. */ +fun Editable.snapshot(): CharSequence = text + +/** A `CharSequence` *parameter* stays declined: only a declared result is converted. */ +fun lengthOf(text: CharSequence): Int = text.length + +/** + * `PointerInputChange`'s and `TextStyle`'s shape: two constructors with a value-class parameter, one of + * them `@Deprecated(level = HIDDEN)`. Both compile to a synthetic `DefaultConstructorMarker` bridge; + * only the hidden one's carries `kotlin.Deprecated`, and Kotlin source cannot call it at all. + */ +class Legacy(val span: Span) { + @Deprecated("gone", level = DeprecationLevel.HIDDEN) + constructor(span: Span, flag: Boolean) : this(span) +} + +/** An ordinary hidden constructor with no value-class parameter: a plain synthetic ``, no bridge. */ +class Plain(val n: Int) { + @Deprecated("gone", level = DeprecationLevel.HIDDEN) + constructor(n: Int, flag: Boolean) : this(n) +}