From 12fb4fe0ba959de5434733752b02aa11ca12c37b Mon Sep 17 00:00:00 2001 From: sergeych Date: Wed, 8 Apr 2026 10:45:01 +0300 Subject: [PATCH] + String.replace --- docs/Regex.md | 12 + docs/tutorial.md | 10 + .../kotlin/net/sergeych/lyng/obj/ObjString.kt | 229 +++++++++++++++++- .../sergeych/lyng/tools/LyngLanguageTools.kt | 23 +- lynglib/src/commonTest/kotlin/ScriptTest.kt | 24 ++ .../lyng/tools/LyngLanguageToolsTest.kt | 21 ++ .../lyng/miniast/CompletionEngineLightTest.kt | 9 +- 7 files changed, 322 insertions(+), 6 deletions(-) diff --git a/docs/Regex.md b/docs/Regex.md index 1bbdc55..6c77790 100644 --- a/docs/Regex.md +++ b/docs/Regex.md @@ -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 ) >>> 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 diff --git a/docs/tutorial.md b/docs/tutorial.md index caee07f..323b1cc 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -1833,6 +1833,14 @@ 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) @@ -1868,6 +1876,8 @@ 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) diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjString.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjString.kt index 2cc0bae..d2736fb 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjString.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjString.kt @@ -21,10 +21,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive -import net.sergeych.lyng.PerfFlags -import net.sergeych.lyng.Pos -import net.sergeych.lyng.RegexCache -import net.sergeych.lyng.Scope +import net.sergeych.lyng.* import net.sergeych.lyng.miniast.* import net.sergeych.lyng.requireScope import net.sergeych.lynon.LynonDecoder @@ -132,6 +129,46 @@ 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 = @@ -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() + val oldValue = requiredArg(0) + val newValue = requiredArg(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() + val oldValue = requiredArg(0) + val newValue = requiredArg(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( diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/tools/LyngLanguageTools.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/tools/LyngLanguageTools.kt index b054a5a..dd3eb30 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/tools/LyngLanguageTools.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/tools/LyngLanguageTools.kt @@ -345,9 +345,30 @@ 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) } diff --git a/lynglib/src/commonTest/kotlin/ScriptTest.kt b/lynglib/src/commonTest/kotlin/ScriptTest.kt index 2b5b4d8..d296f84 100644 --- a/lynglib/src/commonTest/kotlin/ScriptTest.kt +++ b/lynglib/src/commonTest/kotlin/ScriptTest.kt @@ -4160,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 fun extensionsMustBeLocalPerScope() = runTest { val scope1 = Script.newScope() diff --git a/lynglib/src/commonTest/kotlin/net/sergeych/lyng/tools/LyngLanguageToolsTest.kt b/lynglib/src/commonTest/kotlin/net/sergeych/lyng/tools/LyngLanguageToolsTest.kt index 7f893b0..1a26bef 100644 --- a/lynglib/src/commonTest/kotlin/net/sergeych/lyng/tools/LyngLanguageToolsTest.kt +++ b/lynglib/src/commonTest/kotlin/net/sergeych/lyng/tools/LyngLanguageToolsTest.kt @@ -102,6 +102,27 @@ 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 = """ diff --git a/lynglib/src/jvmTest/kotlin/net/sergeych/lyng/miniast/CompletionEngineLightTest.kt b/lynglib/src/jvmTest/kotlin/net/sergeych/lyng/miniast/CompletionEngineLightTest.kt index 4ec8965..4d37b36 100644 --- a/lynglib/src/jvmTest/kotlin/net/sergeych/lyng/miniast/CompletionEngineLightTest.kt +++ b/lynglib/src/jvmTest/kotlin/net/sergeych/lyng/miniast/CompletionEngineLightTest.kt @@ -18,7 +18,6 @@ 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 @@ -96,6 +95,14 @@ 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