Compare commits
4 Commits
d0d79d2f07
...
b6c6ef021a
| Author | SHA1 | Date | |
|---|---|---|---|
| b6c6ef021a | |||
| c6cfd52b01 | |||
| 12fb4fe0ba | |||
| 368ce2ce8c |
@ -121,7 +121,7 @@ which is used in `toString`) and hex encoding:
|
|||||||
## Members
|
## Members
|
||||||
|
|
||||||
| name | meaning | type |
|
| name | meaning | type |
|
||||||
|----------------------------|-----------------------------------------|---------------|
|
|----------------------------|------------------------------------------------|---------------|
|
||||||
| `size` | size | Int |
|
| `size` | size | Int |
|
||||||
| `decodeUtf8` | decode to String using UTF8 rules | Any |
|
| `decodeUtf8` | decode to String using UTF8 rules | Any |
|
||||||
| `+` | buffer concatenation | Any |
|
| `+` | buffer concatenation | Any |
|
||||||
@ -129,6 +129,7 @@ which is used in `toString`) and hex encoding:
|
|||||||
| `hex` | encode to hex strign | String |
|
| `hex` | encode to hex strign | String |
|
||||||
| `Buffer.decodeHex(hexStr) | decode hex string | Buffer |
|
| `Buffer.decodeHex(hexStr) | decode hex string | Buffer |
|
||||||
| `base64` | encode to base64 (url flavor) (2) | String |
|
| `base64` | encode to base64 (url flavor) (2) | String |
|
||||||
|
| `base64std` | encode to base64 (default vocabulary, filling) | String |
|
||||||
| `Buffer.decodeBase64(str)` | decode base64 to new Buffer (2) | Buffer |
|
| `Buffer.decodeBase64(str)` | decode base64 to new Buffer (2) | Buffer |
|
||||||
| `toBitInput()` | create bit input from a byte buffer (3) | |
|
| `toBitInput()` | create bit input from a byte buffer (3) | |
|
||||||
|
|
||||||
|
|||||||
@ -64,6 +64,18 @@ Also, string indexing is Regex-aware, and works like `Regex.find` (_not findall!
|
|||||||
assert( "cd" == ("abcdef"[ "c.".re ] as RegexMatch).value )
|
assert( "cd" == ("abcdef"[ "c.".re ] as RegexMatch).value )
|
||||||
>>> void
|
>>> void
|
||||||
|
|
||||||
|
Regex replacement is exposed on `String.replace` and `String.replaceFirst`:
|
||||||
|
|
||||||
|
assertEquals( "v#.#.#", "v1.2.3".replace( "\d+".re, "#" ) )
|
||||||
|
assertEquals( "v[1].[2].[3]", "v1.2.3".replace( "(\d+)".re ) { m -> "[" + m[1] + "]" } )
|
||||||
|
assertEquals( "year-04-08", "2026-04-08".replaceFirst( "\d+".re, "year" ) )
|
||||||
|
>>> void
|
||||||
|
|
||||||
|
When `replace` takes a plain `String`, it is treated literally, not as a regex pattern:
|
||||||
|
|
||||||
|
assertEquals( "a-b-c", "a.b.c".replace( ".", "-" ) )
|
||||||
|
>>> void
|
||||||
|
|
||||||
|
|
||||||
# Regex class reference
|
# Regex class reference
|
||||||
|
|
||||||
|
|||||||
@ -1833,6 +1833,14 @@ Part match:
|
|||||||
assert( "foo" == ($~ as RegexMatch).value )
|
assert( "foo" == ($~ as RegexMatch).value )
|
||||||
>>> void
|
>>> void
|
||||||
|
|
||||||
|
Replacing text:
|
||||||
|
|
||||||
|
assertEquals("bonono", "banana".replace('a', 'o'))
|
||||||
|
assertEquals("a-b-c", "a.b.c".replace(".", "-")) // string patterns are literal
|
||||||
|
assertEquals("v#.#.#", "v1.2.3".replace("\d+".re, "#"))
|
||||||
|
assertEquals("v[1].[2].[3]", "v1.2.3".replace("(\d+)".re) { m -> "[" + m[1] + "]" })
|
||||||
|
>>> void
|
||||||
|
|
||||||
Repeating the fragment:
|
Repeating the fragment:
|
||||||
|
|
||||||
assertEquals("hellohello", "hello"*2)
|
assertEquals("hellohello", "hello"*2)
|
||||||
@ -1868,6 +1876,8 @@ A typical set of String functions includes:
|
|||||||
| characters | create [List] of characters (1) |
|
| characters | create [List] of characters (1) |
|
||||||
| encodeUtf8() | returns [Buffer] with characters encoded to utf8 |
|
| encodeUtf8() | returns [Buffer] with characters encoded to utf8 |
|
||||||
| matches(re) | matches the regular expression (2) |
|
| matches(re) | matches the regular expression (2) |
|
||||||
|
| replace(old, new) | replace all literal or regex matches; regex needs [Regex] |
|
||||||
|
| replaceFirst(old,new)| replace the first literal or regex match |
|
||||||
| | |
|
| | |
|
||||||
|
|
||||||
(1)
|
(1)
|
||||||
|
|||||||
@ -0,0 +1,20 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
package net.sergeych.lyng.obj
|
||||||
|
|
||||||
|
internal actual fun objListBoundsViolationMessageOrNull(size: Int, index: Int): String? = null
|
||||||
@ -3663,7 +3663,11 @@ class CmdGetIndex(
|
|||||||
val target = frame.storedSlotObj(targetSlot)
|
val target = frame.storedSlotObj(targetSlot)
|
||||||
val index = frame.storedSlotObj(indexSlot)
|
val index = frame.storedSlotObj(indexSlot)
|
||||||
if (target is ObjList && target::class == ObjList::class && index is ObjInt) {
|
if (target is ObjList && target::class == ObjList::class && index is ObjInt) {
|
||||||
frame.storeObjResult(dst, target.getObjAtFast(index.toInt()))
|
val i = index.toInt()
|
||||||
|
objListBoundsViolationMessageOrNull(target.sizeFast(), i)?.let {
|
||||||
|
frame.ensureScope().raiseIndexOutOfBounds(it)
|
||||||
|
}
|
||||||
|
frame.storeObjResult(dst, target.getObjAtFast(i))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val result = target.getAt(frame.ensureScope(), index)
|
val result = target.getAt(frame.ensureScope(), index)
|
||||||
@ -3682,7 +3686,11 @@ class CmdSetIndex(
|
|||||||
val index = frame.storedSlotObj(indexSlot)
|
val index = frame.storedSlotObj(indexSlot)
|
||||||
val value = frame.slotToObj(valueSlot)
|
val value = frame.slotToObj(valueSlot)
|
||||||
if (target is ObjList && target::class == ObjList::class && index is ObjInt) {
|
if (target is ObjList && target::class == ObjList::class && index is ObjInt) {
|
||||||
target.setObjAtFast(index.toInt(), value)
|
val i = index.toInt()
|
||||||
|
objListBoundsViolationMessageOrNull(target.sizeFast(), i)?.let {
|
||||||
|
frame.ensureScope().raiseIndexOutOfBounds(it)
|
||||||
|
}
|
||||||
|
target.setObjAtFast(i, value)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
target.putAt(frame.ensureScope(), index, value)
|
target.putAt(frame.ensureScope(), index, value)
|
||||||
@ -3699,6 +3707,9 @@ class CmdGetIndexInt(
|
|||||||
val target = frame.storedSlotObj(targetSlot)
|
val target = frame.storedSlotObj(targetSlot)
|
||||||
val index = frame.getInt(indexSlot).toInt()
|
val index = frame.getInt(indexSlot).toInt()
|
||||||
if (target is ObjList && target::class == ObjList::class) {
|
if (target is ObjList && target::class == ObjList::class) {
|
||||||
|
objListBoundsViolationMessageOrNull(target.sizeFast(), index)?.let {
|
||||||
|
frame.ensureScope().raiseIndexOutOfBounds(it)
|
||||||
|
}
|
||||||
target.getIntAtFast(index)?.let {
|
target.getIntAtFast(index)?.let {
|
||||||
frame.setInt(dst, it)
|
frame.setInt(dst, it)
|
||||||
return
|
return
|
||||||
@ -3722,6 +3733,9 @@ class CmdSetIndexInt(
|
|||||||
val target = frame.storedSlotObj(targetSlot)
|
val target = frame.storedSlotObj(targetSlot)
|
||||||
val index = frame.getInt(indexSlot).toInt()
|
val index = frame.getInt(indexSlot).toInt()
|
||||||
if (target is ObjList && target::class == ObjList::class) {
|
if (target is ObjList && target::class == ObjList::class) {
|
||||||
|
objListBoundsViolationMessageOrNull(target.sizeFast(), index)?.let {
|
||||||
|
frame.ensureScope().raiseIndexOutOfBounds(it)
|
||||||
|
}
|
||||||
target.setIntAtFast(index, frame.getInt(valueSlot))
|
target.setIntAtFast(index, frame.getInt(valueSlot))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,6 +30,7 @@ import net.sergeych.lynon.LynonDecoder
|
|||||||
import net.sergeych.lynon.LynonEncoder
|
import net.sergeych.lynon.LynonEncoder
|
||||||
import net.sergeych.lynon.LynonType
|
import net.sergeych.lynon.LynonType
|
||||||
import net.sergeych.mp_tools.decodeBase64Url
|
import net.sergeych.mp_tools.decodeBase64Url
|
||||||
|
import net.sergeych.mp_tools.encodeToBase64
|
||||||
import net.sergeych.mp_tools.encodeToBase64Url
|
import net.sergeych.mp_tools.encodeToBase64Url
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
|
|
||||||
@ -39,6 +40,7 @@ open class ObjBuffer(val byteArray: UByteArray) : Obj() {
|
|||||||
|
|
||||||
val hex by lazy { byteArray.encodeToHex("")}
|
val hex by lazy { byteArray.encodeToHex("")}
|
||||||
val base64 by lazy { byteArray.toByteArray().encodeToBase64Url()}
|
val base64 by lazy { byteArray.toByteArray().encodeToBase64Url()}
|
||||||
|
val base64std by lazy { byteArray.toByteArray().encodeToBase64()}
|
||||||
|
|
||||||
fun checkIndex(scope: Scope, index: Obj): Int {
|
fun checkIndex(scope: Scope, index: Obj): Int {
|
||||||
if (index !is ObjInt)
|
if (index !is ObjInt)
|
||||||
@ -196,6 +198,13 @@ open class ObjBuffer(val byteArray: UByteArray) : Obj() {
|
|||||||
moduleName = "lyng.stdlib",
|
moduleName = "lyng.stdlib",
|
||||||
getter = { thisAs<ObjBuffer>().base64.toObj() }
|
getter = { thisAs<ObjBuffer>().base64.toObj() }
|
||||||
)
|
)
|
||||||
|
addPropertyDoc(
|
||||||
|
name = "base64std",
|
||||||
|
doc = "Base64 standard string representation of the buffer.",
|
||||||
|
type = type("lyng.String"),
|
||||||
|
moduleName = "lyng.stdlib",
|
||||||
|
getter = { thisAs<ObjBuffer>().base64std.toObj() }
|
||||||
|
)
|
||||||
addFn("decodeUtf8") {
|
addFn("decodeUtf8") {
|
||||||
ObjString(
|
ObjString(
|
||||||
thisAs<ObjBuffer>().byteArray.toByteArray().decodeToString()
|
thisAs<ObjBuffer>().byteArray.toByteArray().decodeToString()
|
||||||
|
|||||||
@ -171,7 +171,9 @@ open class ObjList(initialList: MutableList<Obj> = mutableListOf()) : Obj() {
|
|||||||
override suspend fun getAt(scope: Scope, index: Obj): Obj {
|
override suspend fun getAt(scope: Scope, index: Obj): Obj {
|
||||||
return when (index) {
|
return when (index) {
|
||||||
is ObjInt -> {
|
is ObjInt -> {
|
||||||
getObjAtFast(index.toInt())
|
val i = index.toInt()
|
||||||
|
objListBoundsViolationMessageOrNull(sizeFast(), i)?.let { scope.raiseIndexOutOfBounds(it) }
|
||||||
|
getObjAtFast(i)
|
||||||
}
|
}
|
||||||
|
|
||||||
is ObjRange -> {
|
is ObjRange -> {
|
||||||
@ -209,7 +211,9 @@ open class ObjList(initialList: MutableList<Obj> = mutableListOf()) : Obj() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
open override suspend fun putAt(scope: Scope, index: Obj, newValue: Obj) {
|
open override suspend fun putAt(scope: Scope, index: Obj, newValue: Obj) {
|
||||||
setObjAtFast(index.toInt(), newValue)
|
val i = index.toInt()
|
||||||
|
objListBoundsViolationMessageOrNull(sizeFast(), i)?.let { scope.raiseIndexOutOfBounds(it) }
|
||||||
|
setObjAtFast(i, newValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun compareTo(scope: Scope, other: Obj): Int {
|
override suspend fun compareTo(scope: Scope, other: Obj): Int {
|
||||||
|
|||||||
@ -0,0 +1,20 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
package net.sergeych.lyng.obj
|
||||||
|
|
||||||
|
internal expect fun objListBoundsViolationMessageOrNull(size: Int, index: Int): String?
|
||||||
@ -21,10 +21,7 @@ import kotlinx.serialization.SerialName
|
|||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import net.sergeych.lyng.PerfFlags
|
import net.sergeych.lyng.*
|
||||||
import net.sergeych.lyng.Pos
|
|
||||||
import net.sergeych.lyng.RegexCache
|
|
||||||
import net.sergeych.lyng.Scope
|
|
||||||
import net.sergeych.lyng.miniast.*
|
import net.sergeych.lyng.miniast.*
|
||||||
import net.sergeych.lyng.requireScope
|
import net.sergeych.lyng.requireScope
|
||||||
import net.sergeych.lynon.LynonDecoder
|
import net.sergeych.lynon.LynonDecoder
|
||||||
@ -132,6 +129,46 @@ data class ObjString(val value: String) : Obj() {
|
|||||||
return JsonPrimitive(value)
|
return JsonPrimitive(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun replaceLiteralChar(oldValue: Char, newValue: String, firstOnly: Boolean): ObjString {
|
||||||
|
if (!firstOnly) return ObjString(value.replace(oldValue.toString(), newValue))
|
||||||
|
val index = value.indexOf(oldValue)
|
||||||
|
if (index < 0) return this
|
||||||
|
return ObjString(value.substring(0, index) + newValue + value.substring(index + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun replaceLiteralString(oldValue: String, newValue: String, firstOnly: Boolean): ObjString =
|
||||||
|
ObjString(
|
||||||
|
if (firstOnly) value.replaceFirst(oldValue, newValue)
|
||||||
|
else value.replace(oldValue, newValue)
|
||||||
|
)
|
||||||
|
|
||||||
|
private suspend fun replaceRegex(
|
||||||
|
scope: ScopeFacade,
|
||||||
|
pattern: Regex,
|
||||||
|
firstOnly: Boolean,
|
||||||
|
replacementProvider: suspend (ObjRegexMatch) -> String
|
||||||
|
): ObjString {
|
||||||
|
val firstMatch = pattern.find(value) ?: return this
|
||||||
|
if (firstOnly) {
|
||||||
|
val replacement = replacementProvider(ObjRegexMatch(firstMatch))
|
||||||
|
val start = firstMatch.range.first
|
||||||
|
val endExclusive = start + firstMatch.value.length
|
||||||
|
return ObjString(value.substring(0, start) + replacement + value.substring(endExclusive))
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = StringBuilder(value.length)
|
||||||
|
var lastIndex = 0
|
||||||
|
for (match in pattern.findAll(value)) {
|
||||||
|
val start = match.range.first
|
||||||
|
val endExclusive = start + match.value.length
|
||||||
|
result.append(value, lastIndex, start)
|
||||||
|
result.append(replacementProvider(ObjRegexMatch(match)))
|
||||||
|
lastIndex = endExclusive
|
||||||
|
}
|
||||||
|
result.append(value, lastIndex, value.length)
|
||||||
|
return ObjString(result.toString())
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val type = object : ObjClass("String", ObjCollection) {
|
val type = object : ObjClass("String", ObjCollection) {
|
||||||
override suspend fun deserialize(scope: Scope, decoder: LynonDecoder, lynonType: LynonType?): Obj =
|
override suspend fun deserialize(scope: Scope, decoder: LynonDecoder, lynonType: LynonType?): Obj =
|
||||||
@ -341,6 +378,190 @@ data class ObjString(val value: String) : Obj() {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
addFnDoc(
|
||||||
|
name = "replace",
|
||||||
|
doc = "Return a copy of this string with all literal or regex matches replaced. String arguments are treated literally; use Regex for regular expressions.",
|
||||||
|
params = listOf(ParamDoc("old"), ParamDoc("new")),
|
||||||
|
returns = type("lyng.String"),
|
||||||
|
moduleName = "lyng.stdlib"
|
||||||
|
) {
|
||||||
|
val source = thisAs<ObjString>()
|
||||||
|
val oldValue = requiredArg<Obj>(0)
|
||||||
|
val newValue = requiredArg<Obj>(1)
|
||||||
|
when (oldValue) {
|
||||||
|
is ObjChar -> {
|
||||||
|
val replacement = when (newValue) {
|
||||||
|
is ObjChar -> newValue.value.toString()
|
||||||
|
is ObjString -> newValue.value
|
||||||
|
else -> raiseIllegalArgument("String.replace(Char, ...) requires Char or String replacement")
|
||||||
|
}
|
||||||
|
source.replaceLiteralChar(oldValue.value, replacement, firstOnly = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
is ObjString -> {
|
||||||
|
val replacement = (newValue as? ObjString)?.value
|
||||||
|
?: raiseIllegalArgument("String.replace(String, ...) requires String replacement")
|
||||||
|
source.replaceLiteralString(oldValue.value, replacement, firstOnly = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
is ObjRegex -> when {
|
||||||
|
newValue is ObjString ->
|
||||||
|
source.replaceRegex(this, oldValue.regex, firstOnly = false) { newValue.value }
|
||||||
|
|
||||||
|
newValue.isInstanceOf("Callable") ->
|
||||||
|
source.replaceRegex(this, oldValue.regex, firstOnly = false) { match ->
|
||||||
|
val transformed = call(newValue, Arguments(match), ObjVoid)
|
||||||
|
(transformed as? ObjString)?.value
|
||||||
|
?: raiseIllegalArgument("String.replace(Regex, transform) callback must return String")
|
||||||
|
}
|
||||||
|
|
||||||
|
else ->
|
||||||
|
raiseIllegalArgument("String.replace(Regex, ...) requires String replacement or a callable")
|
||||||
|
}
|
||||||
|
|
||||||
|
else ->
|
||||||
|
raiseIllegalArgument("String.replace requires Char, String, or Regex as the first argument")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addFnDoc(
|
||||||
|
name = "replaceFirst",
|
||||||
|
doc = "Return a copy of this string with the first literal or regex match replaced. String arguments are treated literally; use Regex for regular expressions.",
|
||||||
|
params = listOf(ParamDoc("old"), ParamDoc("new")),
|
||||||
|
returns = type("lyng.String"),
|
||||||
|
moduleName = "lyng.stdlib"
|
||||||
|
) {
|
||||||
|
val source = thisAs<ObjString>()
|
||||||
|
val oldValue = requiredArg<Obj>(0)
|
||||||
|
val newValue = requiredArg<Obj>(1)
|
||||||
|
when (oldValue) {
|
||||||
|
is ObjChar -> {
|
||||||
|
val replacement = when (newValue) {
|
||||||
|
is ObjChar -> newValue.value.toString()
|
||||||
|
is ObjString -> newValue.value
|
||||||
|
else -> raiseIllegalArgument("String.replaceFirst(Char, ...) requires Char or String replacement")
|
||||||
|
}
|
||||||
|
source.replaceLiteralChar(oldValue.value, replacement, firstOnly = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
is ObjString -> {
|
||||||
|
val replacement = (newValue as? ObjString)?.value
|
||||||
|
?: raiseIllegalArgument("String.replaceFirst(String, ...) requires String replacement")
|
||||||
|
source.replaceLiteralString(oldValue.value, replacement, firstOnly = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
is ObjRegex -> when {
|
||||||
|
newValue is ObjString ->
|
||||||
|
source.replaceRegex(this, oldValue.regex, firstOnly = true) { newValue.value }
|
||||||
|
|
||||||
|
newValue.isInstanceOf("Callable") ->
|
||||||
|
source.replaceRegex(this, oldValue.regex, firstOnly = true) { match ->
|
||||||
|
val transformed = call(newValue, Arguments(match), ObjVoid)
|
||||||
|
(transformed as? ObjString)?.value
|
||||||
|
?: raiseIllegalArgument("String.replaceFirst(Regex, transform) callback must return String")
|
||||||
|
}
|
||||||
|
|
||||||
|
else ->
|
||||||
|
raiseIllegalArgument("String.replaceFirst(Regex, ...) requires String replacement or a callable")
|
||||||
|
}
|
||||||
|
|
||||||
|
else ->
|
||||||
|
raiseIllegalArgument("String.replaceFirst requires Char, String, or Regex as the first argument")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BuiltinDocRegistry.module("lyng.stdlib") {
|
||||||
|
classDoc("String", doc = "") {
|
||||||
|
method(
|
||||||
|
name = "replace",
|
||||||
|
doc = "Replace all occurrences of the given character with another character.",
|
||||||
|
params = listOf(
|
||||||
|
ParamDoc("old", type("lyng.Char")),
|
||||||
|
ParamDoc("new", type("lyng.Char"))
|
||||||
|
),
|
||||||
|
returns = type("lyng.String")
|
||||||
|
)
|
||||||
|
method(
|
||||||
|
name = "replace",
|
||||||
|
doc = "Replace all occurrences of the given character with another character or string.",
|
||||||
|
params = listOf(
|
||||||
|
ParamDoc("old", type("lyng.Char")),
|
||||||
|
ParamDoc("new", type("lyng.String"))
|
||||||
|
),
|
||||||
|
returns = type("lyng.String")
|
||||||
|
)
|
||||||
|
method(
|
||||||
|
name = "replace",
|
||||||
|
doc = "Replace all literal occurrences of the given string with another string.",
|
||||||
|
params = listOf(
|
||||||
|
ParamDoc("old", type("lyng.String")),
|
||||||
|
ParamDoc("new", type("lyng.String"))
|
||||||
|
),
|
||||||
|
returns = type("lyng.String")
|
||||||
|
)
|
||||||
|
method(
|
||||||
|
name = "replace",
|
||||||
|
doc = "Replace all regular-expression matches with the given replacement string.",
|
||||||
|
params = listOf(
|
||||||
|
ParamDoc("pattern", type("lyng.Regex")),
|
||||||
|
ParamDoc("new", type("lyng.String"))
|
||||||
|
),
|
||||||
|
returns = type("lyng.String")
|
||||||
|
)
|
||||||
|
method(
|
||||||
|
name = "replace",
|
||||||
|
doc = "Replace all regular-expression matches using the callback result for each RegexMatch.",
|
||||||
|
params = listOf(
|
||||||
|
ParamDoc("pattern", type("lyng.Regex")),
|
||||||
|
ParamDoc("transform", funType(listOf(type("lyng.RegexMatch")), type("lyng.String")))
|
||||||
|
),
|
||||||
|
returns = type("lyng.String")
|
||||||
|
)
|
||||||
|
method(
|
||||||
|
name = "replaceFirst",
|
||||||
|
doc = "Replace the first occurrence of the given character with another character.",
|
||||||
|
params = listOf(
|
||||||
|
ParamDoc("old", type("lyng.Char")),
|
||||||
|
ParamDoc("new", type("lyng.Char"))
|
||||||
|
),
|
||||||
|
returns = type("lyng.String")
|
||||||
|
)
|
||||||
|
method(
|
||||||
|
name = "replaceFirst",
|
||||||
|
doc = "Replace the first occurrence of the given character with another character or string.",
|
||||||
|
params = listOf(
|
||||||
|
ParamDoc("old", type("lyng.Char")),
|
||||||
|
ParamDoc("new", type("lyng.String"))
|
||||||
|
),
|
||||||
|
returns = type("lyng.String")
|
||||||
|
)
|
||||||
|
method(
|
||||||
|
name = "replaceFirst",
|
||||||
|
doc = "Replace the first literal occurrence of the given string with another string.",
|
||||||
|
params = listOf(
|
||||||
|
ParamDoc("old", type("lyng.String")),
|
||||||
|
ParamDoc("new", type("lyng.String"))
|
||||||
|
),
|
||||||
|
returns = type("lyng.String")
|
||||||
|
)
|
||||||
|
method(
|
||||||
|
name = "replaceFirst",
|
||||||
|
doc = "Replace the first regular-expression match with the given replacement string.",
|
||||||
|
params = listOf(
|
||||||
|
ParamDoc("pattern", type("lyng.Regex")),
|
||||||
|
ParamDoc("new", type("lyng.String"))
|
||||||
|
),
|
||||||
|
returns = type("lyng.String")
|
||||||
|
)
|
||||||
|
method(
|
||||||
|
name = "replaceFirst",
|
||||||
|
doc = "Replace the first regular-expression match using the callback result for the RegexMatch.",
|
||||||
|
params = listOf(
|
||||||
|
ParamDoc("pattern", type("lyng.Regex")),
|
||||||
|
ParamDoc("transform", funType(listOf(type("lyng.RegexMatch")), type("lyng.String")))
|
||||||
|
),
|
||||||
|
returns = type("lyng.String")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
createField(
|
createField(
|
||||||
name = "re",
|
name = "re",
|
||||||
initialValue = ObjProperty(
|
initialValue = ObjProperty(
|
||||||
|
|||||||
@ -345,9 +345,30 @@ object LyngLanguageTools {
|
|||||||
|
|
||||||
fun docAt(analysis: LyngAnalysisResult, offset: Int): LyngSymbolInfo? {
|
fun docAt(analysis: LyngAnalysisResult, offset: Int): LyngSymbolInfo? {
|
||||||
StdlibDocsBootstrap.ensure()
|
StdlibDocsBootstrap.ensure()
|
||||||
val target = definitionAt(analysis, offset) ?: return null
|
|
||||||
val mini = analysis.mini
|
val mini = analysis.mini
|
||||||
val imported = analysis.importedModules
|
val imported = analysis.importedModules
|
||||||
|
val target = definitionAt(analysis, offset) ?: run {
|
||||||
|
val word = DocLookupUtils.wordRangeAt(analysis.text, offset) ?: return null
|
||||||
|
val name = analysis.text.substring(word.first, word.second)
|
||||||
|
val dotPos = DocLookupUtils.prevNonWs(analysis.text, word.first - 1)
|
||||||
|
if (dotPos >= 0 && analysis.text[dotPos] == '.') {
|
||||||
|
val receiverClass = DocLookupUtils.guessReceiverClassViaMini(
|
||||||
|
mini,
|
||||||
|
analysis.text,
|
||||||
|
dotPos,
|
||||||
|
imported,
|
||||||
|
analysis.binding
|
||||||
|
) ?: DocLookupUtils.guessReceiverClass(analysis.text, dotPos, imported, mini)
|
||||||
|
if (receiverClass != null) {
|
||||||
|
LyngSymbolTarget(
|
||||||
|
name = name,
|
||||||
|
kind = SymbolKind.Function,
|
||||||
|
range = TextRange(word.first, word.second),
|
||||||
|
containerName = receiverClass
|
||||||
|
)
|
||||||
|
} else null
|
||||||
|
} else null
|
||||||
|
} ?: return null
|
||||||
val name = target.name
|
val name = target.name
|
||||||
|
|
||||||
val local = mini?.let { findLocalDecl(it, analysis.text, name, target.range.start) }
|
val local = mini?.let { findLocalDecl(it, analysis.text, name, target.range.start) }
|
||||||
|
|||||||
@ -3233,6 +3233,7 @@ class ScriptTest {
|
|||||||
assertEquals( "hello", b.decodeUtf8() )
|
assertEquals( "hello", b.decodeUtf8() )
|
||||||
|
|
||||||
println(b.base64)
|
println(b.base64)
|
||||||
|
println(b.base64std)
|
||||||
println(b.hex)
|
println(b.hex)
|
||||||
|
|
||||||
assertEquals( b, Buffer.decodeBase64(b.base64) )
|
assertEquals( b, Buffer.decodeBase64(b.base64) )
|
||||||
@ -4159,6 +4160,30 @@ class ScriptTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testStringReplaceVariants() = runTest {
|
||||||
|
eval(
|
||||||
|
"""
|
||||||
|
assertEquals("bonono", "banana".replace('a', 'o'))
|
||||||
|
assertEquals("bxnxnx", "banana".replace('a', "x"))
|
||||||
|
assertEquals("a-b-c", "a.b.c".replace(".", "-"))
|
||||||
|
assertEquals("bonana", "banana".replaceFirst('a', 'o'))
|
||||||
|
assertEquals("bxnana", "banana".replaceFirst('a', "x"))
|
||||||
|
assertEquals("a-b.c", "a.b.c".replaceFirst(".", "-"))
|
||||||
|
|
||||||
|
assertEquals("foo-#-bar-#", "foo-42-bar-17".replace("\d+".re, "#"))
|
||||||
|
assertEquals("foo-[42]-bar-[17]", "foo-42-bar-17".replace("(\d+)".re) { m ->
|
||||||
|
"[" + m[1] + "]"
|
||||||
|
})
|
||||||
|
assertEquals("foo-[42]-bar-17", "foo-42-bar-17".replaceFirst("(\d+)".re) { m ->
|
||||||
|
"[" + m[1] + "]"
|
||||||
|
})
|
||||||
|
assertEquals("YEAR-04-08", "2026-04-08".replaceFirst("\d+".re, "YEAR"))
|
||||||
|
"""
|
||||||
|
.trimIndent()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun extensionsMustBeLocalPerScope() = runTest {
|
fun extensionsMustBeLocalPerScope() = runTest {
|
||||||
val scope1 = Script.newScope()
|
val scope1 = Script.newScope()
|
||||||
|
|||||||
@ -16,8 +16,16 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import net.sergeych.lyng.Script
|
||||||
import net.sergeych.lyng.eval
|
import net.sergeych.lyng.eval
|
||||||
|
import net.sergeych.lyng.evalNamed
|
||||||
|
import net.sergeych.lyng.obj.ObjException
|
||||||
|
import net.sergeych.lyng.obj.ObjInstance
|
||||||
|
import net.sergeych.lyng.obj.getLyngExceptionMessage
|
||||||
|
import net.sergeych.lyng.obj.getLyngExceptionStackTrace
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class StdlibTest {
|
class StdlibTest {
|
||||||
@Test
|
@Test
|
||||||
@ -373,4 +381,59 @@ class StdlibTest {
|
|||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testErrorCatching() = runTest {
|
||||||
|
val error = evalNamed("testErrorCatching", """
|
||||||
|
val src = [1,2,3]
|
||||||
|
val d = launch {
|
||||||
|
try {
|
||||||
|
for( i in 0..3 ) src[i]
|
||||||
|
} catch(e) {
|
||||||
|
e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.await()
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
val scope = when (error) {
|
||||||
|
is ObjException -> error.scope
|
||||||
|
is ObjInstance -> error.instanceScope
|
||||||
|
else -> Script.newScope()
|
||||||
|
}
|
||||||
|
val trace = error.getLyngExceptionStackTrace(scope)
|
||||||
|
val renderedTrace = trace.list.map { it.toString(scope).value }
|
||||||
|
|
||||||
|
assertEquals("Index 3 out of bounds for length 3", error.getLyngExceptionMessage(scope))
|
||||||
|
assertTrue(trace.list.size >= 2, "expected at least await and coroutine frames, got ${trace.list.size}")
|
||||||
|
assertTrue(
|
||||||
|
renderedTrace.all { it.contains("testErrorCatching:") },
|
||||||
|
"unexpected trace entries: $renderedTrace"
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
renderedTrace.any { it.contains("launch") || it.contains("src[i]") },
|
||||||
|
"trace should include the coroutine body: $renderedTrace"
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
renderedTrace.any { it.contains("d.await()") },
|
||||||
|
"trace should include await site: $renderedTrace"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCatchToIt() = runTest {
|
||||||
|
eval("""
|
||||||
|
var x = 0
|
||||||
|
try {
|
||||||
|
throw "msg1"
|
||||||
|
x = 1
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
assert(it.message == "msg1")
|
||||||
|
x = 2
|
||||||
|
}
|
||||||
|
assertEquals(2, x)
|
||||||
|
""".trimIndent())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -102,6 +102,27 @@ class LyngLanguageToolsTest {
|
|||||||
assertTrue(local.typeText?.contains("String") == true, "Expected type for local, got ${local.typeText}")
|
assertTrue(local.typeText?.contains("String") == true, "Expected type for local, got ${local.typeText}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun languageTools_docs_for_builtin_string_replace() = runTest {
|
||||||
|
val code = """
|
||||||
|
val s = "a.b.c".replace(".", "-")
|
||||||
|
""".trimIndent()
|
||||||
|
val res = LyngLanguageTools.analyze(code, "replace_docs.lyng")
|
||||||
|
val replaceOffset = code.indexOf("replace")
|
||||||
|
val doc = LyngLanguageTools.docAt(res, replaceOffset)
|
||||||
|
assertNotNull(doc, "Docs should resolve for built-in String.replace")
|
||||||
|
assertEquals("replace", doc.target.name)
|
||||||
|
assertEquals("String", doc.target.containerName)
|
||||||
|
assertTrue(
|
||||||
|
doc.signature?.startsWith("fun String.replace(") == true,
|
||||||
|
"Expected String.replace signature, got ${doc.signature}"
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
doc.doc?.summary?.contains("string with all literal or regex matches replaced") == true,
|
||||||
|
"Expected replace summary, got ${doc.doc?.summary}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun languageTools_definition_and_usages() = runTest {
|
fun languageTools_definition_and_usages() = runTest {
|
||||||
val code = """
|
val code = """
|
||||||
|
|||||||
@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
package net.sergeych.lyng.obj
|
||||||
|
|
||||||
|
internal actual fun objListBoundsViolationMessageOrNull(size: Int, index: Int): String? =
|
||||||
|
if (index < 0 || index >= size) "Index $index out of bounds for length $size" else null
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
package net.sergeych.lyng.obj
|
||||||
|
|
||||||
|
internal actual fun objListBoundsViolationMessageOrNull(size: Int, index: Int): String? = null
|
||||||
@ -18,7 +18,6 @@
|
|||||||
package net.sergeych.lyng.miniast
|
package net.sergeych.lyng.miniast
|
||||||
|
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlin.test.Ignore
|
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertFalse
|
import kotlin.test.assertFalse
|
||||||
import kotlin.test.assertNotNull
|
import kotlin.test.assertNotNull
|
||||||
@ -96,6 +95,14 @@ class CompletionEngineLightTest {
|
|||||||
val ns = names(items)
|
val ns = names(items)
|
||||||
assertTrue(ns.isNotEmpty(), "String members should be suggested")
|
assertTrue(ns.isNotEmpty(), "String members should be suggested")
|
||||||
assertFalse(ns.contains("Path"))
|
assertFalse(ns.contains("Path"))
|
||||||
|
assertTrue(ns.contains("replace"), "Expected String.replace in completion items, got: $ns")
|
||||||
|
assertTrue(ns.contains("replaceFirst"), "Expected String.replaceFirst in completion items, got: $ns")
|
||||||
|
val replaceItem = items.firstOrNull { it.name == "replace" }
|
||||||
|
assertNotNull(replaceItem, "Expected to find replace in String members")
|
||||||
|
assertTrue(
|
||||||
|
replaceItem.tailText?.contains("overloads") == true,
|
||||||
|
"Expected replace completion to show overloads, got tailText=${replaceItem.tailText}"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@ -0,0 +1,20 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
package net.sergeych.lyng.obj
|
||||||
|
|
||||||
|
internal actual fun objListBoundsViolationMessageOrNull(size: Int, index: Int): String? = null
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
package net.sergeych.lyng.obj
|
||||||
|
|
||||||
|
internal actual fun objListBoundsViolationMessageOrNull(size: Int, index: Int): String? =
|
||||||
|
if (index < 0 || index >= size) "Index $index out of bounds for length $size" else null
|
||||||
@ -20,6 +20,10 @@ import kotlinx.browser.window
|
|||||||
import org.jetbrains.compose.web.dom.*
|
import org.jetbrains.compose.web.dom.*
|
||||||
import org.w3c.dom.HTMLElement
|
import org.w3c.dom.HTMLElement
|
||||||
|
|
||||||
|
private const val DESKTOP_TOC_BREAKPOINT_PX = 992
|
||||||
|
|
||||||
|
fun isDesktopTocLayout(viewportWidthPx: Int): Boolean = viewportWidthPx >= DESKTOP_TOC_BREAKPOINT_PX
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun App() {
|
fun App() {
|
||||||
var route by remember { mutableStateOf(currentRoute()) }
|
var route by remember { mutableStateOf(currentRoute()) }
|
||||||
@ -29,6 +33,8 @@ fun App() {
|
|||||||
var activeTocId by remember { mutableStateOf<String?>(null) }
|
var activeTocId by remember { mutableStateOf<String?>(null) }
|
||||||
var contentEl by remember { mutableStateOf<HTMLElement?>(null) }
|
var contentEl by remember { mutableStateOf<HTMLElement?>(null) }
|
||||||
var navEl by remember { mutableStateOf<HTMLElement?>(null) }
|
var navEl by remember { mutableStateOf<HTMLElement?>(null) }
|
||||||
|
var mobileTocExpanded by remember { mutableStateOf(false) }
|
||||||
|
var isDesktopToc by remember { mutableStateOf(isDesktopTocLayout(window.innerWidth)) }
|
||||||
val isDocsRoute = route.startsWith("docs/")
|
val isDocsRoute = route.startsWith("docs/")
|
||||||
val docKey = stripFragment(route)
|
val docKey = stripFragment(route)
|
||||||
|
|
||||||
@ -50,7 +56,11 @@ fun App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
val handler: (org.w3c.dom.events.Event) -> Unit = { updateNavbarOffsetVar() }
|
val handler: (org.w3c.dom.events.Event) -> Unit = {
|
||||||
|
updateNavbarOffsetVar()
|
||||||
|
isDesktopToc = isDesktopTocLayout(window.innerWidth)
|
||||||
|
}
|
||||||
|
isDesktopToc = isDesktopTocLayout(window.innerWidth)
|
||||||
window.addEventListener("resize", handler)
|
window.addEventListener("resize", handler)
|
||||||
onDispose { window.removeEventListener("resize", handler) }
|
onDispose { window.removeEventListener("resize", handler) }
|
||||||
}
|
}
|
||||||
@ -61,13 +71,18 @@ fun App() {
|
|||||||
onDispose { window.removeEventListener("hashchange", listener) }
|
onDispose { window.removeEventListener("hashchange", listener) }
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(activeTocId) {
|
LaunchedEffect(activeTocId, isDesktopToc) {
|
||||||
|
if (!isDesktopToc) return@LaunchedEffect
|
||||||
val activeId = activeTocId ?: return@LaunchedEffect
|
val activeId = activeTocId ?: return@LaunchedEffect
|
||||||
val nav = navEl ?: return@LaunchedEffect
|
val nav = navEl ?: return@LaunchedEffect
|
||||||
val activeLink = nav.querySelector("a[data-toc-id=\"$activeId\"]") as? HTMLElement
|
val activeLink = nav.querySelector("a[data-toc-id=\"$activeId\"]") as? HTMLElement
|
||||||
activeLink?.scrollIntoView(js("({block: 'nearest', behavior: 'smooth'})"))
|
activeLink?.scrollIntoView(js("({block: 'nearest', behavior: 'smooth'})"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(docKey) {
|
||||||
|
mobileTocExpanded = false
|
||||||
|
}
|
||||||
|
|
||||||
PageTemplate(title = when {
|
PageTemplate(title = when {
|
||||||
isDocsRoute -> null
|
isDocsRoute -> null
|
||||||
route.startsWith("authors") -> "Authors"
|
route.startsWith("authors") -> "Authors"
|
||||||
@ -78,40 +93,29 @@ fun App() {
|
|||||||
Div({ classes("row", "gy-4") }) {
|
Div({ classes("row", "gy-4") }) {
|
||||||
if (isDocsRoute) {
|
if (isDocsRoute) {
|
||||||
Div({ classes("col-12", "col-lg-3") }) {
|
Div({ classes("col-12", "col-lg-3") }) {
|
||||||
Nav({
|
if (toc.isNotEmpty() && !isDesktopToc) {
|
||||||
classes("position-sticky")
|
Button(attrs = {
|
||||||
attr("style", "top: calc(var(--navbar-offset) + 1rem); max-height: calc(100vh - var(--navbar-offset) - 2rem); overflow-y: auto;")
|
classes("btn", "btn-outline-secondary", "w-100", "mb-3", "d-lg-none")
|
||||||
ref {
|
attr("type", "button")
|
||||||
navEl = it
|
attr("aria-expanded", mobileTocExpanded.toString())
|
||||||
onDispose { navEl = null }
|
attr("aria-controls", "docs-toc-nav")
|
||||||
}
|
onClick { mobileTocExpanded = !mobileTocExpanded }
|
||||||
}) {
|
}) {
|
||||||
H2({ classes("h6", "text-uppercase", "text-muted") }) { Text("On this page") }
|
Text(if (mobileTocExpanded) "Hide contents" else "Show contents")
|
||||||
Ul({ classes("list-unstyled") }) {
|
|
||||||
toc.forEach { item ->
|
|
||||||
Li({ classes("mb-1") }) {
|
|
||||||
val pad = when (item.level) { 1 -> "0"; 2 -> "0.75rem"; else -> "1.5rem" }
|
|
||||||
val routeNoFrag = route.substringBefore('#')
|
|
||||||
val tocHref = "#/$routeNoFrag#${item.id}"
|
|
||||||
A(attrs = {
|
|
||||||
attr("href", tocHref)
|
|
||||||
attr("data-toc-id", item.id)
|
|
||||||
attr("style", "padding-left: $pad")
|
|
||||||
classes("link-body-emphasis", "text-decoration-none")
|
|
||||||
if (activeTocId == item.id) {
|
|
||||||
classes("fw-semibold", "text-primary")
|
|
||||||
attr("aria-current", "true")
|
|
||||||
}
|
|
||||||
onClick {
|
|
||||||
it.preventDefault()
|
|
||||||
window.location.hash = tocHref
|
|
||||||
contentEl?.ownerDocument?.getElementById(item.id)
|
|
||||||
?.let { (it as? HTMLElement)?.scrollIntoView() }
|
|
||||||
}
|
|
||||||
}) { Text(item.title) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (toc.isNotEmpty() && (isDesktopToc || mobileTocExpanded)) {
|
||||||
|
TocNav(
|
||||||
|
toc = toc,
|
||||||
|
route = route,
|
||||||
|
activeTocId = activeTocId,
|
||||||
|
isDesktopToc = isDesktopToc,
|
||||||
|
contentEl = contentEl,
|
||||||
|
onNavigate = {
|
||||||
|
if (!isDesktopToc) mobileTocExpanded = false
|
||||||
|
},
|
||||||
|
onNavEl = { navEl = it }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -141,3 +145,59 @@ fun App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TocNav(
|
||||||
|
toc: List<TocItem>,
|
||||||
|
route: String,
|
||||||
|
activeTocId: String?,
|
||||||
|
isDesktopToc: Boolean,
|
||||||
|
contentEl: HTMLElement?,
|
||||||
|
onNavigate: () -> Unit,
|
||||||
|
onNavEl: (HTMLElement?) -> Unit,
|
||||||
|
) {
|
||||||
|
Nav({
|
||||||
|
id("docs-toc-nav")
|
||||||
|
classes(if (isDesktopToc) "position-sticky" else "docs-mobile-toc", "mb-3", "mb-lg-0")
|
||||||
|
attr(
|
||||||
|
"style",
|
||||||
|
if (isDesktopToc) {
|
||||||
|
"top: calc(var(--navbar-offset) + 1rem); max-height: calc(100vh - var(--navbar-offset) - 2rem); overflow-y: auto;"
|
||||||
|
} else {
|
||||||
|
"max-height: min(50vh, 24rem); overflow-y: auto;"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ref {
|
||||||
|
onNavEl(it)
|
||||||
|
onDispose { onNavEl(null) }
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
H2({ classes("h6", "text-uppercase", "text-muted", "mb-2") }) { Text("On this page") }
|
||||||
|
Ul({ classes("list-unstyled", "mb-0") }) {
|
||||||
|
toc.forEach { item ->
|
||||||
|
Li({ classes("mb-1") }) {
|
||||||
|
val pad = when (item.level) { 1 -> "0"; 2 -> "0.75rem"; else -> "1.5rem" }
|
||||||
|
val routeNoFrag = route.substringBefore('#')
|
||||||
|
val tocHref = "#/$routeNoFrag#${item.id}"
|
||||||
|
A(attrs = {
|
||||||
|
attr("href", tocHref)
|
||||||
|
attr("data-toc-id", item.id)
|
||||||
|
attr("style", "display: block; padding-left: $pad")
|
||||||
|
classes("link-body-emphasis", "text-decoration-none")
|
||||||
|
if (activeTocId == item.id) {
|
||||||
|
classes("fw-semibold", "text-primary")
|
||||||
|
attr("aria-current", "true")
|
||||||
|
}
|
||||||
|
onClick {
|
||||||
|
it.preventDefault()
|
||||||
|
onNavigate()
|
||||||
|
window.location.hash = tocHref
|
||||||
|
contentEl?.ownerDocument?.getElementById(item.id)
|
||||||
|
?.let { heading -> (heading as? HTMLElement)?.scrollIntoView() }
|
||||||
|
}
|
||||||
|
}) { Text(item.title) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -180,17 +180,34 @@ fun ensureDocsLayoutStyles() {
|
|||||||
.markdown-body h1:first-child {
|
.markdown-body h1:first-child {
|
||||||
margin-top: 0 !important;
|
margin-top: 0 !important;
|
||||||
}
|
}
|
||||||
|
.docs-mobile-toc {
|
||||||
|
position: static !important;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: var(--bs-body-bg, #fff);
|
||||||
|
}
|
||||||
/* Hide scrollbar for the TOC nav but allow scrolling */
|
/* Hide scrollbar for the TOC nav but allow scrolling */
|
||||||
nav.position-sticky::-webkit-scrollbar {
|
nav.position-sticky::-webkit-scrollbar {
|
||||||
width: 4px;
|
width: 4px;
|
||||||
}
|
}
|
||||||
|
.docs-mobile-toc::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
nav.position-sticky::-webkit-scrollbar-thumb {
|
nav.position-sticky::-webkit-scrollbar-thumb {
|
||||||
background: rgba(128,128,128,0.2);
|
background: rgba(128,128,128,0.2);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
.docs-mobile-toc::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(128,128,128,0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
nav.position-sticky:hover::-webkit-scrollbar-thumb {
|
nav.position-sticky:hover::-webkit-scrollbar-thumb {
|
||||||
background: rgba(128,128,128,0.5);
|
background: rgba(128,128,128,0.5);
|
||||||
}
|
}
|
||||||
|
.docs-mobile-toc:hover::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(128,128,128,0.5);
|
||||||
|
}
|
||||||
"""
|
"""
|
||||||
.trimIndent()
|
.trimIndent()
|
||||||
)
|
)
|
||||||
|
|||||||
33
site/src/jsTest/kotlin/AppResponsiveTocTest.kt
Normal file
33
site/src/jsTest/kotlin/AppResponsiveTocTest.kt
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class AppResponsiveTocTest {
|
||||||
|
@Test
|
||||||
|
fun tocIsCollapsedBelowLgBreakpoint() {
|
||||||
|
assertFalse(isDesktopTocLayout(991))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tocStaysDesktopAtLgBreakpointAndAbove() {
|
||||||
|
assertTrue(isDesktopTocLayout(992))
|
||||||
|
assertTrue(isDesktopTocLayout(1280))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user