fix #23 string formatting and manipulations

This commit is contained in:
Sergey Chernov 2025-06-14 11:53:18 +04:00
parent 1db1f12be3
commit 19eae213ec
13 changed files with 207 additions and 40 deletions

0
docs/String.md Normal file
View File

View File

@ -1051,31 +1051,79 @@ Are the same as in string literals with little difference:
## String details ## String details
Strings are much like Kotlin ones: Strings are arrays of Unicode characters. It can be indexed, and indexing will
return a valid Unicode character at position. No utf hassle:
"Hello".length "Парашют"[5]
>>> 'ю'
Its length is, of course, in characters:
"разум".length
>>> 5 >>> 5
And supports growing set of kotlin-borrowed operations, see below, for example: And supports growing set of kotlin-borrowed operations, see below, for example:
assertEquals("Hell", "Hello".dropLast(1)) assertEquals("Hell", "Hello".dropLast(1))
>>> void >>> void
To format a string use sprintf-style modifiers like:
val a = "hello"
val b = 11
assertEquals( "hello:11", "%s:%d"(a, b) )
assertEquals( " hello: 11", "%6s:%6d"(a, b) )
assertEquals( "hello :11 ", "%-6s:%-6d"(a, b) )
>>> void
List of format specifiers closely resembles C sprintf() one. See [format specifiers](https://github.com/sergeych/mp_stools?tab=readme-ov-file#sprintf-syntax-summary), this is doe using [mp_stools kotlin multiplatform library](https://github.com/sergeych/mp_stools). Currently supported Lyng types are `String`, `Int`, `Real`, `Bool`, the rest are displayed using their `toString()` representation.
This list will be extended.
To get the substring use:
assertEquals("pult", "catapult".takeLast(4))
assertEquals("cat", "catapult".take(3))
assertEquals("cat", "catapult".dropLast(5))
assertEquals("pult", "catapult".drop(4))
>>> void
And to get a portion you can slice it with range as the index:
assertEquals( "tap", "catapult"[ 2 .. 4 ])
assertEquals( "tap", "catapult"[ 2 ..< 5 ])
>>> void
Open-ended ranges could be used to get start and end too:
assertEquals( "cat", "catapult"[ ..< 3 ])
assertEquals( "cat", "catapult"[ .. 2 ])
assertEquals( "pult", "catapult"[ 4.. ])
>>> void
### String operations ### String operations
Concatenation is a `+`: `"hello " + name` works as expected. No confusion. Concatenation is a `+`: `"hello " + name` works as expected. No confusion.
Typical set of String functions includes: Typical set of String functions includes:
| fun/prop | description / notes | | fun/prop | description / notes |
|------------------|------------------------------------------------------------| |--------------------|------------------------------------------------------------|
| lower() | change case to unicode upper | | lower() | change case to unicode upper |
| upper() | change case to unicode lower | | upper() | change case to unicode lower |
| startsWith(prefix) | true if starts with a prefix | | startsWith(prefix) | true if starts with a prefix |
| take(n) | get a new string from up to n first characters | | endsWith(prefix) | true if ends with a prefix |
| takeLast(n) | get a new string from up to n last characters | | take(n) | get a new string from up to n first characters |
| drop(n) | get a new string dropping n first chars, or empty string | | takeLast(n) | get a new string from up to n last characters |
| dropLast(n) | get a new string dropping n last chars, or empty string | | drop(n) | get a new string dropping n first chars, or empty string |
| size | size in characters like `length` because String is [Array] | | dropLast(n) | get a new string dropping n last chars, or empty string |
| size | size in characters like `length` because String is [Array] |
| (args...) | sprintf-like formatting, see [string formatting] |
| [index] | character at index |
| [Range] | substring at range |
| s1 + s2 | concatenation |
| s1 += s2 | self-modifying concatenation |
@ -1111,5 +1159,5 @@ See [math functions](math.md). Other general purpose functions are:
[Iterator]: Iterator.md [Iterator]: Iterator.md
[Real]: Real.md [Real]: Real.md
[Range]: Range.md [Range]: Range.md
[String]: String.md
[string formatting]: https://github.com/sergeych/mp_stools?tab=readme-ov-file#sprintf-syntax-summary

View File

@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
group = "net.sergeych" group = "net.sergeych"
version = "0.6.-SNAPSHOT" version = "0.6.4-SNAPSHOT"
buildscript { buildscript {
repositories { repositories {

View File

@ -33,6 +33,13 @@ data class Arguments(val list: List<Obj>,val tailBlockMode: Boolean = false) : L
return list.first() return list.first()
} }
/**
* Convert to list of kotlin objects, see [Obj.toKotlin].
*/
suspend fun toKotlinList(context: Context): List<Any?> {
return list.map { it.toKotlin(context) }
}
companion object { companion object {
val EMPTY = Arguments(emptyList()) val EMPTY = Arguments(emptyList())
fun from(values: Collection<Obj>) = Arguments(values.toList()) fun from(values: Collection<Obj>) = Arguments(values.toList())

View File

@ -59,7 +59,7 @@ class Compiler(
parseBlock(cc) parseBlock(cc)
} }
Token.Type.RBRACE -> { Token.Type.RBRACE, Token.Type.RBRACKET -> {
cc.previous() cc.previous()
return null return null
} }
@ -226,8 +226,7 @@ class Compiler(
val index = parseStatement(cc) ?: throw ScriptError(t.pos, "Expecting index expression") val index = parseStatement(cc) ?: throw ScriptError(t.pos, "Expecting index expression")
cc.skipTokenOfType(Token.Type.RBRACKET, "missing ']' at the end of the list literal") cc.skipTokenOfType(Token.Type.RBRACKET, "missing ']' at the end of the list literal")
operand = Accessor({ cxt -> operand = Accessor({ cxt ->
val i = (index.execute(cxt) as? ObjInt)?.value?.toInt() val i = index.execute(cxt)
?: cxt.raiseError("index must be integer")
val x = left.getter(cxt).value val x = left.getter(cxt).value
if( x == ObjNull && isOptional) ObjNull.asReadonly if( x == ObjNull && isOptional) ObjNull.asReadonly
else x.getAt(cxt, i).asMutable else x.getAt(cxt, i).asMutable
@ -341,10 +340,10 @@ class Compiler(
} }
Token.Type.DOTDOT, Token.Type.DOTDOTLT -> { Token.Type.DOTDOT, Token.Type.DOTDOTLT -> {
// closed-range operator // range operator
val isEndInclusive = t.type == Token.Type.DOTDOT val isEndInclusive = t.type == Token.Type.DOTDOT
val left = operand val left = operand
val right = parseStatement(cc) val right = parseExpression(cc)
operand = Accessor { operand = Accessor {
ObjRange( ObjRange(
left?.getter?.invoke(it)?.value ?: ObjNull, left?.getter?.invoke(it)?.value ?: ObjNull,
@ -366,11 +365,15 @@ class Compiler(
} ?: parseLambdaExpression(cc) } ?: parseLambdaExpression(cc)
} }
Token.Type.RBRACKET -> {
cc.previous()
return operand
}
else -> { else -> {
cc.previous() cc.previous()
operand?.let { return it } operand?.let { return it }
operand = parseAccessor(cc) ?: throw ScriptError(t.pos, "Expecting expression") operand = parseAccessor(cc) ?: return null //throw ScriptError(t.pos, "Expecting expression")
} }
} }
} }
@ -1115,7 +1118,7 @@ class Compiler(
var breakCaught = false var breakCaught = false
if (size > 0) { if (size > 0) {
var current = runCatching { sourceObj.getAt(forContext, 0) } var current = runCatching { sourceObj.getAt(forContext, ObjInt(0)) }
.getOrElse { .getOrElse {
throw ScriptError( throw ScriptError(
tOp.pos, tOp.pos,
@ -1142,7 +1145,7 @@ class Compiler(
} }
} else result = body.execute(forContext) } else result = body.execute(forContext)
if (++index >= size) break if (++index >= size) break
current = sourceObj.getAt(forContext, index) current = sourceObj.getAt(forContext, ObjInt(index.toLong()))
} }
} }
if (!breakCaught && elseStatement != null) { if (!breakCaught && elseStatement != null) {

View File

@ -42,6 +42,9 @@ data class Accessor(
} }
open class Obj { open class Obj {
val isNull by lazy { this === ObjNull }
var isFrozen: Boolean = false var isFrozen: Boolean = false
private val monitor = Mutex() private val monitor = Mutex()
@ -176,6 +179,13 @@ open class Obj {
context.raiseNotImplemented() context.raiseNotImplemented()
} }
/**
* Convert Lyng object to its Kotlin counterpart
*/
open suspend fun toKotlin(context: Context): Any? {
return toString()
}
fun willMutate(context: Context) { fun willMutate(context: Context) {
if (isFrozen) context.raiseError("attempt to mutate frozen object") if (isFrozen) context.raiseError("attempt to mutate frozen object")
} }
@ -201,7 +211,7 @@ open class Obj {
if (field.isMutable) field.value = newValue else context.raiseError("can't assign to read-only field: $name") if (field.isMutable) field.value = newValue else context.raiseError("can't assign to read-only field: $name")
} }
open suspend fun getAt(context: Context, index: Int): Obj { open suspend fun getAt(context: Context, index: Obj): Obj {
context.raiseNotImplemented("indexing") context.raiseNotImplemented("indexing")
} }
@ -297,7 +307,7 @@ object ObjNull : Obj() {
context.raiseNPE() context.raiseNPE()
} }
override suspend fun getAt(context: Context, index: Int): Obj { override suspend fun getAt(context: Context, index: Obj): Obj {
context.raiseNPE() context.raiseNPE()
} }
@ -310,6 +320,10 @@ object ObjNull : Obj() {
} }
override fun toString(): String = "null" override fun toString(): String = "null"
override suspend fun toKotlin(context: Context): Any? {
return null
}
} }
interface Numeric { interface Numeric {

View File

@ -18,6 +18,10 @@ data class ObjBool(val value: Boolean) : Obj() {
override suspend fun logicalOr(context: Context, other: Obj): Obj = ObjBool(value || other.toBool()) override suspend fun logicalOr(context: Context, other: Obj): Obj = ObjBool(value || other.toBool())
override suspend fun toKotlin(context: Context): Any {
return value
}
companion object { companion object {
val type = ObjClass("Bool") val type = ObjClass("Bool")
} }

View File

@ -162,7 +162,7 @@ val ObjArray by lazy {
addFn("contains", isOpen = true) { addFn("contains", isOpen = true) {
val obj = args.firstAndOnly() val obj = args.firstAndOnly()
for( i in 0..< thisObj.invokeInstanceMethod(this, "size").toInt()) { for( i in 0..< thisObj.invokeInstanceMethod(this, "size").toInt()) {
if( thisObj.getAt(this, i).compareTo(this, obj) == 0 ) return@addFn ObjTrue if( thisObj.getAt(this, ObjInt(i.toLong())).compareTo(this, obj) == 0 ) return@addFn ObjTrue
} }
ObjFalse ObjFalse
} }

View File

@ -72,6 +72,10 @@ data class ObjInt(var value: Long) : Obj(), Numeric {
} else null } else null
} }
override suspend fun toKotlin(context: Context): Any {
return value
}
companion object { companion object {
val type = ObjClass("Int") val type = ObjClass("Int")
} }

View File

@ -11,15 +11,15 @@ class ObjList(val list: MutableList<Obj> = mutableListOf()) : Obj() {
list.joinToString(separator = ", ") { it.inspect() } list.joinToString(separator = ", ") { it.inspect() }
}]" }]"
fun normalize(context: Context, index: Int, allowisEndInclusive: Boolean = false): Int { fun normalize(context: Context, index: Int, allowsEndInclusive: Boolean = false): Int {
val i = if (index < 0) list.size + index else index val i = if (index < 0) list.size + index else index
if (allowisEndInclusive && i == list.size) return i if (allowsEndInclusive && i == list.size) return i
if (i !in list.indices) context.raiseError("index $index out of bounds for size ${list.size}") if (i !in list.indices) context.raiseError("index $index out of bounds for size ${list.size}")
return i return i
} }
override suspend fun getAt(context: Context, index: Int): Obj { override suspend fun getAt(context: Context, index: Obj): Obj {
val i = normalize(context, index) val i = normalize(context, index.toInt())
return list[i] return list[i]
} }
@ -82,6 +82,10 @@ class ObjList(val list: MutableList<Obj> = mutableListOf()) : Obj() {
override val objClass: ObjClass override val objClass: ObjClass
get() = type get() = type
override suspend fun toKotlin(context: Context): Any {
return list.map { it.toKotlin(context) }
}
companion object { companion object {
val type = ObjClass("List", ObjArray).apply { val type = ObjClass("List", ObjArray).apply {
@ -92,7 +96,7 @@ class ObjList(val list: MutableList<Obj> = mutableListOf()) : Obj() {
) )
addFn("getAt") { addFn("getAt") {
requireExactCount(1) requireExactCount(1)
thisAs<ObjList>().getAt(this, requiredArg<ObjInt>(0).value.toInt()) thisAs<ObjList>().getAt(this, requiredArg<Obj>(0))
} }
addFn("putAt") { addFn("putAt") {
requireExactCount(2) requireExactCount(2)
@ -113,7 +117,7 @@ class ObjList(val list: MutableList<Obj> = mutableListOf()) : Obj() {
val l = thisAs<ObjList>() val l = thisAs<ObjList>()
var index = l.normalize( var index = l.normalize(
this, requiredArg<ObjInt>(0).value.toInt(), this, requiredArg<ObjInt>(0).value.toInt(),
allowisEndInclusive = true allowsEndInclusive = true
) )
for (i in 1..<args.size) l.list.add(index++, args[i]) for (i in 1..<args.size) l.list.add(index++, args[i])
ObjVoid ObjVoid

View File

@ -36,6 +36,13 @@ data class ObjReal(val value: Double) : Obj(), Numeric {
override suspend fun mod(context: Context, other: Obj): Obj = override suspend fun mod(context: Context, other: Obj): Obj =
ObjReal(this.value % other.toDouble()) ObjReal(this.value % other.toDouble())
/**
* Returns unboxed Double value
*/
override suspend fun toKotlin(context: Context): Any {
return value
}
companion object { companion object {
val type: ObjClass = ObjClass("Real").apply { val type: ObjClass = ObjClass("Real").apply {
createField( createField(

View File

@ -2,11 +2,20 @@ package net.sergeych.lyng
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import net.sergeych.sprintf.sprintf
@Serializable @Serializable
@SerialName("string") @SerialName("string")
data class ObjString(val value: String) : Obj() { data class ObjString(val value: String) : Obj() {
// fun normalize(context: Context, index: Int, allowsEndInclusive: Boolean = false): Int {
// val i = if (index < 0) value.length + index else index
// if (allowsEndInclusive && i == value.length) return i
// if (i !in value.indices) context.raiseError("index $index out of bounds for length ${value.length} of \"$value\"")
// return i
// }
override suspend fun compareTo(context: Context, other: Obj): Int { override suspend fun compareTo(context: Context, other: Obj): Int {
if (other !is ObjString) return -2 if (other !is ObjString) return -2
return this.value.compareTo(other.value) return this.value.compareTo(other.value)
@ -27,8 +36,21 @@ data class ObjString(val value: String) : Obj() {
return ObjString(value + other.asStr.value) return ObjString(value + other.asStr.value)
} }
override suspend fun getAt(context: Context, index: Int): Obj { override suspend fun getAt(context: Context, index: Obj): Obj {
return ObjChar(value[index]) if( index is ObjInt ) return ObjChar(value[index.toInt()])
if( index is ObjRange ) {
val start = if(index.start == null || index.start.isNull) 0 else index.start.toInt()
val end = if( index.end == null || index.end.isNull ) value.length else {
val e = index.end.toInt()
if( index.isEndInclusive) e + 1 else e
}
return ObjString(value.substring(start, end))
}
context.raiseArgumentError("String index must be Int or Range")
}
override suspend fun callOn(context: Context): Obj {
return ObjString(this.value.sprintf(*context.args.toKotlinList(context).toTypedArray()))
} }
override suspend fun contains(context: Context, other: Obj): Boolean { override suspend fun contains(context: Context, other: Obj): Boolean {
@ -41,10 +63,12 @@ data class ObjString(val value: String) : Obj() {
companion object { companion object {
val type = ObjClass("String").apply { val type = ObjClass("String").apply {
addConst("startsWith", addFn("startsWith") {
statement { ObjBool(thisAs<ObjString>().value.startsWith(requiredArg<ObjString>(0).value))
ObjBool(thisAs<ObjString>().value.startsWith(requiredArg<ObjString>(0).value)) }
}) addFn("endsWith") {
ObjBool(thisAs<ObjString>().value.endsWith(requiredArg<ObjString>(0).value))
}
addConst("length", addConst("length",
statement { ObjInt(thisAs<ObjString>().value.length.toLong()) } statement { ObjInt(thisAs<ObjString>().value.length.toLong()) }
) )

View File

@ -1056,7 +1056,7 @@ class ScriptTest {
} }
@Test @Test
fun testIntOpenRangeInclusive() = runTest { fun testIntClosedRangeInclusive() = runTest {
eval( eval(
""" """
val r = 10 .. 20 val r = 10 .. 20
@ -1090,7 +1090,7 @@ class ScriptTest {
} }
@Test @Test
fun testIntOpenRangeExclusive() = runTest { fun testIntClosedRangeExclusive() = runTest {
eval( eval(
""" """
val r = 10 ..< 20 val r = 10 ..< 20
@ -1126,7 +1126,7 @@ class ScriptTest {
} }
@Test @Test
fun testIntOpenRangeInExclusive() = runTest { fun testIntClosedRangeInExclusive() = runTest {
eval( eval(
""" """
assert( (1..3) !in (1..<3) ) assert( (1..3) !in (1..<3) )
@ -1135,6 +1135,39 @@ class ScriptTest {
) )
} }
@Test
fun testOpenStartRanges() = runTest {
eval("""
var r = ..5
assert( r::class == Range)
assert( r.start == null)
assert( r.end == 5)
assert( r.isEndInclusive)
r = ..< 5
assert( r::class == Range)
assert( r.start == null)
assert( r.end == 5)
assert( !r.isEndInclusive)
assert( r.start == null)
assert( (-2..3) in r)
assert( (-2..12) !in r)
""".trimIndent())
}
@Test
fun testOpenEndRanges() = runTest {
eval("""
var r = 5..
assert( r::class == Range)
assert( r.end == null)
assert( r.start == 5)
""".trimIndent())
}
@Test @Test
fun testCharacterRange() = runTest { fun testCharacterRange() = runTest {
eval( eval(
@ -2133,4 +2166,23 @@ class ScriptTest {
assert(s.length == 2) assert(s.length == 2)
""".trimIndent()) """.trimIndent())
} }
@Test
fun testSprintf() = runTest {
eval("""
assertEquals( "123.45", "%3.2f"(123.451678) )
assertEquals( "123.45: hello", "%3.2f: %s"(123.451678, "hello") )
assertEquals( "123.45: true", "%3.2f: %s"(123.451678, true) )
""".trimIndent())
}
@Test
fun testSubstringRangeFailure() = runTest {
eval("""
assertEquals("pult", "catapult"[4..])
assertEquals("cat", "catapult"[..2])
""".trimIndent()
)
}
} }