+ 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 )
|
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)
|
||||||
|
|||||||
@ -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) }
|
||||||
|
|||||||
@ -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
|
@Test
|
||||||
fun extensionsMustBeLocalPerScope() = runTest {
|
fun extensionsMustBeLocalPerScope() = runTest {
|
||||||
val scope1 = Script.newScope()
|
val scope1 = Script.newScope()
|
||||||
|
|||||||
@ -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 = """
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user