Compare commits

..

No commits in common. "b6c6ef021aeed4ec0268aa0c4a2078bd133e6255" and "d0d79d2f07b29a54ae6629ea660c372dc447119b" have entirely different histories.

21 changed files with 56 additions and 696 deletions

View File

@ -120,18 +120,17 @@ which is used in `toString`) and hex encoding:
## Members
| name | meaning | type |
|----------------------------|------------------------------------------------|---------------|
| `size` | size | Int |
| `decodeUtf8` | decode to String using UTF8 rules | Any |
| `+` | buffer concatenation | Any |
| `toMutable()` | create a mutable copy | MutableBuffer |
| `hex` | encode to hex strign | String |
| `Buffer.decodeHex(hexStr) | decode hex string | Buffer |
| `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 |
| `toBitInput()` | create bit input from a byte buffer (3) | |
| name | meaning | type |
|----------------------------|-----------------------------------------|---------------|
| `size` | size | Int |
| `decodeUtf8` | decode to String using UTF8 rules | Any |
| `+` | buffer concatenation | Any |
| `toMutable()` | create a mutable copy | MutableBuffer |
| `hex` | encode to hex strign | String |
| `Buffer.decodeHex(hexStr) | decode hex string | Buffer |
| `base64` | encode to base64 (url flavor) (2) | String |
| `Buffer.decodeBase64(str)` | decode base64 to new Buffer (2) | Buffer |
| `toBitInput()` | create bit input from a byte buffer (3) | |
(1)
: optimized implementation that override `Iterable` one

View File

@ -64,18 +64,6 @@ Also, string indexing is Regex-aware, and works like `Regex.find` (_not findall!
assert( "cd" == ("abcdef"[ "c.".re ] as RegexMatch).value )
>>> 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

View File

@ -1833,14 +1833,6 @@ Part match:
assert( "foo" == ($~ as RegexMatch).value )
>>> 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:
assertEquals("hellohello", "hello"*2)
@ -1876,8 +1868,6 @@ A typical set of String functions includes:
| characters | create [List] of characters (1) |
| encodeUtf8() | returns [Buffer] with characters encoded to utf8 |
| 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)

View File

@ -1,20 +0,0 @@
/*
* 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

View File

@ -3663,11 +3663,7 @@ class CmdGetIndex(
val target = frame.storedSlotObj(targetSlot)
val index = frame.storedSlotObj(indexSlot)
if (target is ObjList && target::class == ObjList::class && index is ObjInt) {
val i = index.toInt()
objListBoundsViolationMessageOrNull(target.sizeFast(), i)?.let {
frame.ensureScope().raiseIndexOutOfBounds(it)
}
frame.storeObjResult(dst, target.getObjAtFast(i))
frame.storeObjResult(dst, target.getObjAtFast(index.toInt()))
return
}
val result = target.getAt(frame.ensureScope(), index)
@ -3686,11 +3682,7 @@ class CmdSetIndex(
val index = frame.storedSlotObj(indexSlot)
val value = frame.slotToObj(valueSlot)
if (target is ObjList && target::class == ObjList::class && index is ObjInt) {
val i = index.toInt()
objListBoundsViolationMessageOrNull(target.sizeFast(), i)?.let {
frame.ensureScope().raiseIndexOutOfBounds(it)
}
target.setObjAtFast(i, value)
target.setObjAtFast(index.toInt(), value)
return
}
target.putAt(frame.ensureScope(), index, value)
@ -3707,9 +3699,6 @@ class CmdGetIndexInt(
val target = frame.storedSlotObj(targetSlot)
val index = frame.getInt(indexSlot).toInt()
if (target is ObjList && target::class == ObjList::class) {
objListBoundsViolationMessageOrNull(target.sizeFast(), index)?.let {
frame.ensureScope().raiseIndexOutOfBounds(it)
}
target.getIntAtFast(index)?.let {
frame.setInt(dst, it)
return
@ -3733,9 +3722,6 @@ class CmdSetIndexInt(
val target = frame.storedSlotObj(targetSlot)
val index = frame.getInt(indexSlot).toInt()
if (target is ObjList && target::class == ObjList::class) {
objListBoundsViolationMessageOrNull(target.sizeFast(), index)?.let {
frame.ensureScope().raiseIndexOutOfBounds(it)
}
target.setIntAtFast(index, frame.getInt(valueSlot))
return
}

View File

@ -30,7 +30,6 @@ import net.sergeych.lynon.LynonDecoder
import net.sergeych.lynon.LynonEncoder
import net.sergeych.lynon.LynonType
import net.sergeych.mp_tools.decodeBase64Url
import net.sergeych.mp_tools.encodeToBase64
import net.sergeych.mp_tools.encodeToBase64Url
import kotlin.math.min
@ -40,7 +39,6 @@ open class ObjBuffer(val byteArray: UByteArray) : Obj() {
val hex by lazy { byteArray.encodeToHex("")}
val base64 by lazy { byteArray.toByteArray().encodeToBase64Url()}
val base64std by lazy { byteArray.toByteArray().encodeToBase64()}
fun checkIndex(scope: Scope, index: Obj): Int {
if (index !is ObjInt)
@ -198,13 +196,6 @@ open class ObjBuffer(val byteArray: UByteArray) : Obj() {
moduleName = "lyng.stdlib",
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") {
ObjString(
thisAs<ObjBuffer>().byteArray.toByteArray().decodeToString()

View File

@ -171,9 +171,7 @@ open class ObjList(initialList: MutableList<Obj> = mutableListOf()) : Obj() {
override suspend fun getAt(scope: Scope, index: Obj): Obj {
return when (index) {
is ObjInt -> {
val i = index.toInt()
objListBoundsViolationMessageOrNull(sizeFast(), i)?.let { scope.raiseIndexOutOfBounds(it) }
getObjAtFast(i)
getObjAtFast(index.toInt())
}
is ObjRange -> {
@ -211,9 +209,7 @@ open class ObjList(initialList: MutableList<Obj> = mutableListOf()) : Obj() {
}
open override suspend fun putAt(scope: Scope, index: Obj, newValue: Obj) {
val i = index.toInt()
objListBoundsViolationMessageOrNull(sizeFast(), i)?.let { scope.raiseIndexOutOfBounds(it) }
setObjAtFast(i, newValue)
setObjAtFast(index.toInt(), newValue)
}
override suspend fun compareTo(scope: Scope, other: Obj): Int {

View File

@ -1,20 +0,0 @@
/*
* 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?

View File

@ -21,7 +21,10 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import net.sergeych.lyng.*
import net.sergeych.lyng.PerfFlags
import net.sergeych.lyng.Pos
import net.sergeych.lyng.RegexCache
import net.sergeych.lyng.Scope
import net.sergeych.lyng.miniast.*
import net.sergeych.lyng.requireScope
import net.sergeych.lynon.LynonDecoder
@ -129,46 +132,6 @@ data class ObjString(val value: String) : Obj() {
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 {
val type = object : ObjClass("String", ObjCollection) {
override suspend fun deserialize(scope: Scope, decoder: LynonDecoder, lynonType: LynonType?): Obj =
@ -378,190 +341,6 @@ 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(
name = "re",
initialValue = ObjProperty(

View File

@ -345,30 +345,9 @@ object LyngLanguageTools {
fun docAt(analysis: LyngAnalysisResult, offset: Int): LyngSymbolInfo? {
StdlibDocsBootstrap.ensure()
val target = definitionAt(analysis, offset) ?: return null
val mini = analysis.mini
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 local = mini?.let { findLocalDecl(it, analysis.text, name, target.range.start) }

View File

@ -3233,7 +3233,6 @@ class ScriptTest {
assertEquals( "hello", b.decodeUtf8() )
println(b.base64)
println(b.base64std)
println(b.hex)
assertEquals( b, Buffer.decodeBase64(b.base64) )
@ -4160,30 +4159,6 @@ 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
fun extensionsMustBeLocalPerScope() = runTest {
val scope1 = Script.newScope()

View File

@ -16,16 +16,8 @@
*/
import kotlinx.coroutines.test.runTest
import net.sergeych.lyng.Script
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.assertEquals
import kotlin.test.assertTrue
class StdlibTest {
@Test
@ -381,59 +373,4 @@ class StdlibTest {
""".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())
}
}

View File

@ -102,27 +102,6 @@ class LyngLanguageToolsTest {
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
fun languageTools_definition_and_usages() = runTest {
val code = """

View File

@ -1,21 +0,0 @@
/*
* 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

View File

@ -1,20 +0,0 @@
/*
* 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

View File

@ -18,6 +18,7 @@
package net.sergeych.lyng.miniast
import kotlinx.coroutines.runBlocking
import kotlin.test.Ignore
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
@ -95,14 +96,6 @@ class CompletionEngineLightTest {
val ns = names(items)
assertTrue(ns.isNotEmpty(), "String members should be suggested")
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

View File

@ -1,20 +0,0 @@
/*
* 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

View File

@ -1,21 +0,0 @@
/*
* 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

View File

@ -20,10 +20,6 @@ import kotlinx.browser.window
import org.jetbrains.compose.web.dom.*
import org.w3c.dom.HTMLElement
private const val DESKTOP_TOC_BREAKPOINT_PX = 992
fun isDesktopTocLayout(viewportWidthPx: Int): Boolean = viewportWidthPx >= DESKTOP_TOC_BREAKPOINT_PX
@Composable
fun App() {
var route by remember { mutableStateOf(currentRoute()) }
@ -33,8 +29,6 @@ fun App() {
var activeTocId by remember { mutableStateOf<String?>(null) }
var contentEl 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 docKey = stripFragment(route)
@ -56,11 +50,7 @@ fun App() {
}
DisposableEffect(Unit) {
val handler: (org.w3c.dom.events.Event) -> Unit = {
updateNavbarOffsetVar()
isDesktopToc = isDesktopTocLayout(window.innerWidth)
}
isDesktopToc = isDesktopTocLayout(window.innerWidth)
val handler: (org.w3c.dom.events.Event) -> Unit = { updateNavbarOffsetVar() }
window.addEventListener("resize", handler)
onDispose { window.removeEventListener("resize", handler) }
}
@ -71,18 +61,13 @@ fun App() {
onDispose { window.removeEventListener("hashchange", listener) }
}
LaunchedEffect(activeTocId, isDesktopToc) {
if (!isDesktopToc) return@LaunchedEffect
LaunchedEffect(activeTocId) {
val activeId = activeTocId ?: return@LaunchedEffect
val nav = navEl ?: return@LaunchedEffect
val activeLink = nav.querySelector("a[data-toc-id=\"$activeId\"]") as? HTMLElement
activeLink?.scrollIntoView(js("({block: 'nearest', behavior: 'smooth'})"))
}
LaunchedEffect(docKey) {
mobileTocExpanded = false
}
PageTemplate(title = when {
isDocsRoute -> null
route.startsWith("authors") -> "Authors"
@ -93,29 +78,40 @@ fun App() {
Div({ classes("row", "gy-4") }) {
if (isDocsRoute) {
Div({ classes("col-12", "col-lg-3") }) {
if (toc.isNotEmpty() && !isDesktopToc) {
Button(attrs = {
classes("btn", "btn-outline-secondary", "w-100", "mb-3", "d-lg-none")
attr("type", "button")
attr("aria-expanded", mobileTocExpanded.toString())
attr("aria-controls", "docs-toc-nav")
onClick { mobileTocExpanded = !mobileTocExpanded }
}) {
Text(if (mobileTocExpanded) "Hide contents" else "Show contents")
Nav({
classes("position-sticky")
attr("style", "top: calc(var(--navbar-offset) + 1rem); max-height: calc(100vh - var(--navbar-offset) - 2rem); overflow-y: auto;")
ref {
navEl = it
onDispose { navEl = null }
}
}) {
H2({ classes("h6", "text-uppercase", "text-muted") }) { Text("On this page") }
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 }
)
}
}
}
@ -145,59 +141,3 @@ 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) }
}
}
}
}
}

View File

@ -180,34 +180,17 @@ fun ensureDocsLayoutStyles() {
.markdown-body h1:first-child {
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 */
nav.position-sticky::-webkit-scrollbar {
width: 4px;
}
.docs-mobile-toc::-webkit-scrollbar {
width: 4px;
}
nav.position-sticky::-webkit-scrollbar-thumb {
background: rgba(128,128,128,0.2);
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 {
background: rgba(128,128,128,0.5);
}
.docs-mobile-toc:hover::-webkit-scrollbar-thumb {
background: rgba(128,128,128,0.5);
}
"""
.trimIndent()
)

View File

@ -1,33 +0,0 @@
/*
* 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))
}
}