+ String.replace

This commit is contained in:
Sergey Chernov 2026-04-08 10:45:01 +03:00
parent 368ce2ce8c
commit 12fb4fe0ba
7 changed files with 322 additions and 6 deletions

View File

@ -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

View File

@ -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)

View File

@ -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<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,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) }

View File

@ -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()

View File

@ -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 = """

View File

@ -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