+ String.replace
This commit is contained in:
parent
368ce2ce8c
commit
12fb4fe0ba
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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) }
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 = """
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user