From 512dda59848dd609c35145ee1de79a785c3db2f1 Mon Sep 17 00:00:00 2001 From: sergeych Date: Tue, 3 Jun 2025 10:44:42 +0400 Subject: [PATCH] fix #5 loop optimization fixed arguments modifying bug added samples and samplebooks w/tests --- docs/samples/combinatorics.lyng.md | 44 +++++++++ docs/samples/happy_numbers.lyng | 19 ++++ docs/tutorial.md | 1 + gradle/libs.versions.toml | 2 + library/build.gradle.kts | 3 + .../kotlin/net/sergeych/lyng/Compiler.kt | 63 +++++++----- .../net/sergeych/lyng/CompilerContext.kt | 35 ++++++- .../kotlin/net/sergeych/lyng/Context.kt | 2 + .../kotlin/net/sergeych/lyng/Parser.kt | 21 ++++ .../kotlin/net/sergeych/lyng/Pos.kt | 15 +++ .../kotlin/net/sergeych/lyng/Script.kt | 5 + library/src/commonTest/kotlin/ScriptTest.kt | 20 ++++ library/src/jvmTest/kotlin/BookTest.kt | 99 ++++++++++++++----- library/src/jvmTest/kotlin/SamplesTest.kt | 35 +++++++ 14 files changed, 311 insertions(+), 53 deletions(-) create mode 100644 docs/samples/combinatorics.lyng.md create mode 100644 docs/samples/happy_numbers.lyng create mode 100644 library/src/jvmTest/kotlin/SamplesTest.kt diff --git a/docs/samples/combinatorics.lyng.md b/docs/samples/combinatorics.lyng.md new file mode 100644 index 0000000..b816255 --- /dev/null +++ b/docs/samples/combinatorics.lyng.md @@ -0,0 +1,44 @@ +# Sample combinatorics calculations + +The trivial to start with, the factorialorial calculation: + + fun factorial(n) { + if( n < 1 ) + 1 + else { + var result = 1 + var cnt = 2 + while( cnt <= n ) result = result * cnt++ + } + } + +Let's test it: + + assert(factorial(2) == 2) + assert(factorial(3) == 6) + assert(factorial(4) == 24) + assert(factorial(5) == 120) + +Now let's calculate _combination_, or the polynomial coefficient $C^n_k$. It is trivial also, the formulae is: + +$$C^n_k = \frac {n!} {k! (n-k)!} $$ + +We can simplify it a little, as $ n ≥ k $, we can remove $k!$ from the fraction: + +$$C^n_k = \frac {(k+1)(k+1)...n} { (n-k)!} $$ + +Now the code is much more effective: + + fun C(n,k) { + var result = k+1 + var ck = result + 1 + while( ck <= n ) result *= ck++ + result / factorial(n-k) + } + + println(C(10,3)) + assert( C(10, 3) == 120 ) + +to be continued... + + diff --git a/docs/samples/happy_numbers.lyng b/docs/samples/happy_numbers.lyng new file mode 100644 index 0000000..3686142 --- /dev/null +++ b/docs/samples/happy_numbers.lyng @@ -0,0 +1,19 @@ +/* + Count "happy tickets": 6-digit numbers where sum of first three + digits is equal to those of last three. Just to demonstrate and + test the Lyng way. It is not meant to be effective. +*/ + +fun naiveCountHappyNumbers() { + var count = 0 + for( n1 in 0..9 ) + for( n2 in 0..9 ) + for( n3 in 0..9 ) + for( n4 in 0..9 ) + for( n5 in 0..9 ) + for( n6 in 0..9 ) + if( n1 + n2 + n3 == n4 + n5 + n6 ) count++ + count +} + +assert( naiveCountHappyNumbers() == 55252 ) diff --git a/docs/tutorial.md b/docs/tutorial.md index 2899d46..9c5c3b4 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -2,6 +2,7 @@ gitea: none include_toc: true --- + # Lyng tutorial Lyng is a very simple language, where we take only most important and popular features from diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c2aa089..0796b68 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,12 +5,14 @@ android-minSdk = "24" android-compileSdk = "34" kotlinx-coroutines = "1.10.1" mp_bintools = "0.1.12" +firebaseCrashlyticsBuildtools = "3.0.3" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } mp_bintools = { module = "net.sergeych:mp_bintools", version.ref = "mp_bintools" } +firebase-crashlytics-buildtools = { group = "com.google.firebase", name = "firebase-crashlytics-buildtools", version.ref = "firebaseCrashlyticsBuildtools" } [plugins] androidLibrary = { id = "com.android.library", version.ref = "agp" } diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 422dc34..250c429 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -72,6 +72,9 @@ android { targetCompatibility = JavaVersion.VERSION_11 } } +dependencies { + implementation(libs.firebase.crashlytics.buildtools) +} mavenPublishing { publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) diff --git a/library/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/library/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index 41c2624..4a1c011 100644 --- a/library/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/library/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -652,7 +652,6 @@ class Compiler( val label = getLabel(cc)?.also { cc.labels += it } val start = ensureLparen(cc) - // for - in? val tVar = cc.next() if (tVar.type != Token.Type.ID) throw ScriptError(tVar.pos, "Bad for statement: expected loop variable") @@ -661,8 +660,10 @@ class Compiler( // in loop val source = parseStatement(cc) ?: throw ScriptError(start, "Bad for statement: expected expression") ensureRparen(cc) - val body = parseStatement(cc) ?: throw ScriptError(start, "Bad for statement: expected loop body") + val (canBreak, body) = cc.parseLoop { + parseStatement(cc) ?: throw ScriptError(start, "Bad for statement: expected loop body") + } // possible else clause cc.skipTokenOfType(Token.Type.NEWLINE, isOptional = true) val elseStatement = if (cc.next().let { it.type == Token.Type.ID && it.value == "else" }) { @@ -683,7 +684,7 @@ class Compiler( val sourceObj = source.execute(forContext) if (sourceObj.isInstanceOf(ObjIterable)) { - loopIterable(forContext, sourceObj, loopSO, body, elseStatement, label) + loopIterable(forContext, sourceObj, loopSO, body, elseStatement, label, canBreak) } else { val size = runCatching { sourceObj.invokeInstanceMethod(forContext, "size").toInt() } .getOrElse { throw ScriptError(tOp.pos, "object is not enumerable: no size", it) } @@ -703,19 +704,22 @@ class Compiler( var index = 0 while (true) { loopSO.value = current - try { - result = body.execute(forContext) - } catch (lbe: LoopBreakContinueException) { - if (lbe.label == label || lbe.label == null) { - breakCaught = true - if (lbe.doContinue) continue - else { - result = lbe.result - break - } - } else - throw lbe + if (canBreak) { + try { + result = body.execute(forContext) + } catch (lbe: LoopBreakContinueException) { + if (lbe.label == label || lbe.label == null) { + breakCaught = true + if (lbe.doContinue) continue + else { + result = lbe.result + break + } + } else + throw lbe + } } + else result = body.execute(forContext) if (++index >= size) break current = sourceObj.getAt(forContext, index) } @@ -734,19 +738,25 @@ class Compiler( private suspend fun loopIterable( forContext: Context, sourceObj: Obj, loopVar: StoredObj, - body: Statement, elseStatement: Statement?, label: String? + body: Statement, elseStatement: Statement?, label: String?, + catchBreak: Boolean ): Obj { val iterObj = sourceObj.invokeInstanceMethod(forContext, "iterator") var result: Obj = ObjVoid while (iterObj.invokeInstanceMethod(forContext, "hasNext").toBool()) { - try { + if (catchBreak) + try { + loopVar.value = iterObj.invokeInstanceMethod(forContext, "next") + result = body.execute(forContext) + } catch (lbe: LoopBreakContinueException) { + if (lbe.label == label || lbe.label == null) { + if (lbe.doContinue) continue + } + return lbe.result + } + else { loopVar.value = iterObj.invokeInstanceMethod(forContext, "next") result = body.execute(forContext) - } catch (lbe: LoopBreakContinueException) { - if (lbe.label == label || lbe.label == null) { - if (lbe.doContinue) continue - } - return lbe.result } } return elseStatement?.execute(forContext) ?: result @@ -818,6 +828,8 @@ class Compiler( parseStatement(cc) } else null + cc.addBreak() + return statement(start) { val returnValue = resultExpr?.execute(it)// ?: ObjVoid throw LoopBreakContinueException( @@ -840,6 +852,7 @@ class Compiler( // check that label is defined cc.ensureLabelIsValid(start, it) } + cc.addBreak() return statement(start) { throw LoopBreakContinueException( @@ -943,8 +956,10 @@ class Compiler( val fnBody = statement(t.pos) { callerContext -> callerContext.pos = start - // restore closure where the function was defined: - val context = closure ?: callerContext.raiseError("bug: closure not set") + // restore closure where the function was defined, and making a copy of it + // for local space (otherwise it will write local stuff to closure!) + val context = closure?.copy() ?: callerContext.raiseError("bug: closure not set") + // load params from caller context for ((i, d) in params.withIndex()) { if (i < callerContext.args.size) diff --git a/library/src/commonMain/kotlin/net/sergeych/lyng/CompilerContext.kt b/library/src/commonMain/kotlin/net/sergeych/lyng/CompilerContext.kt index 5d6c719..32ac545 100644 --- a/library/src/commonMain/kotlin/net/sergeych/lyng/CompilerContext.kt +++ b/library/src/commonMain/kotlin/net/sergeych/lyng/CompilerContext.kt @@ -3,15 +3,31 @@ package net.sergeych.lyng internal class CompilerContext(val tokens: List) { val labels = mutableSetOf() + var breakFound = false + private set + + var loopLevel = 0 + private set + + inline fun parseLoop(f: () -> T): Pair { + if (++loopLevel == 0) breakFound = false + val result = f() + return Pair(breakFound, result).also { + --loopLevel + } + } + var currentIndex = 0 fun hasNext() = currentIndex < tokens.size fun hasPrevious() = currentIndex > 0 fun next() = tokens.getOrElse(currentIndex) { throw IllegalStateException("No next token") }.also { currentIndex++ } - fun previous() = if( !hasPrevious() ) throw IllegalStateException("No previous token") else tokens[--currentIndex] + fun previous() = if (!hasPrevious()) throw IllegalStateException("No previous token") else tokens[--currentIndex] - fun savePos() = currentIndex - fun restorePos(pos: Int) { currentIndex = pos } + fun savePos() = currentIndex + fun restorePos(pos: Int) { + currentIndex = pos + } fun ensureLabelIsValid(pos: Pos, label: String) { if (label !in labels) @@ -42,7 +58,11 @@ internal class CompilerContext(val tokens: List) { * @return `true` if the token was skipped * @throws ScriptError if [isOptional] is `false` and next token is not of [tokenType] */ - fun skipTokenOfType(tokenType: Token.Type, errorMessage: String="expected ${tokenType.name}", isOptional: Boolean = false): Boolean { + fun skipTokenOfType( + tokenType: Token.Type, + errorMessage: String = "expected ${tokenType.name}", + isOptional: Boolean = false + ): Boolean { val t = next() return if (t.type != tokenType) { if (!isOptional) { @@ -57,7 +77,8 @@ internal class CompilerContext(val tokens: List) { @Suppress("unused") fun skipTokens(vararg tokenTypes: Token.Type) { - while( next().type in tokenTypes ) { /**/ } + while (next().type in tokenTypes) { /**/ + } previous() } @@ -73,4 +94,8 @@ internal class CompilerContext(val tokens: List) { } } + inline fun addBreak() { + breakFound = true + } + } \ No newline at end of file diff --git a/library/src/commonMain/kotlin/net/sergeych/lyng/Context.kt b/library/src/commonMain/kotlin/net/sergeych/lyng/Context.kt index 0ccafc7..67b1231 100644 --- a/library/src/commonMain/kotlin/net/sergeych/lyng/Context.kt +++ b/library/src/commonMain/kotlin/net/sergeych/lyng/Context.kt @@ -65,6 +65,8 @@ class Context( fun copy(pos: Pos, args: Arguments = Arguments.EMPTY,newThisObj: Obj? = null): Context = Context(this, args, pos, newThisObj ?: thisObj) + fun copy() = Context(this, args, pos, thisObj) + fun addItem(name: String, isMutable: Boolean, value: Obj?): StoredObj { return StoredObj(value, isMutable).also { objects.put(name, it) } } diff --git a/library/src/commonMain/kotlin/net/sergeych/lyng/Parser.kt b/library/src/commonMain/kotlin/net/sergeych/lyng/Parser.kt index d9b7ed3..20f8903 100644 --- a/library/src/commonMain/kotlin/net/sergeych/lyng/Parser.kt +++ b/library/src/commonMain/kotlin/net/sergeych/lyng/Parser.kt @@ -106,6 +106,16 @@ private class Parser(fromPos: Pos) { Token(loadToEnd().trim(), from, Token.Type.SINLGE_LINE_COMMENT) } + '*' -> { + pos.advance() + Token( + loadTo("*/")?.trim() + ?: throw ScriptError(from, "Unterminated multiline comment"), + from, + Token.Type.MULTILINE_COMMENT + ) + } + '=' -> { pos.advance() Token("/=", from, Token.Type.SLASHASSIGN) @@ -164,10 +174,12 @@ private class Parser(fromPos: Pos) { pos.advance() Token("!in", from, Token.Type.NOTIN) } + 's' -> { pos.advance() Token("!is", from, Token.Type.NOTIS) } + else -> { pos.back() Token("!", from, Token.Type.NOT) @@ -397,6 +409,15 @@ private class Parser(fromPos: Pos) { return result.toString() } + private fun loadTo(str: String): String? { + val result = StringBuilder() + while (!pos.readFragment(str)) { + if (pos.end) return null + result.append(pos.currentChar); pos.advance() + } + return result.toString() + } + /** * next non-whitespace char (newline are skipped too) or null if EOF */ diff --git a/library/src/commonMain/kotlin/net/sergeych/lyng/Pos.kt b/library/src/commonMain/kotlin/net/sergeych/lyng/Pos.kt index 03be38d..babe4ea 100644 --- a/library/src/commonMain/kotlin/net/sergeych/lyng/Pos.kt +++ b/library/src/commonMain/kotlin/net/sergeych/lyng/Pos.kt @@ -46,12 +46,15 @@ class MutablePos(private val from: Pos) { } } + fun resetTo(pos: Pos) { line = pos.line; column = pos.column } + fun back() { if (column > 0) column-- else if (line > 0) column = lines[--line].length - 1 else throw IllegalStateException("can't go back from line 0, column 0") } + val currentChar: Char get() { if (end) return 0.toChar() @@ -60,6 +63,18 @@ class MutablePos(private val from: Pos) { else current[column] } + /** + * If the next characters are equal to the fragment, advances the position and returns true. + * Otherwise, does nothing and returns false. + */ + fun readFragment(fragment: String): Boolean { + val mark = toPos() + for(ch in fragment) + if( currentChar != ch ) { resetTo(mark); return false } + else advance() + return true + } + override fun toString() = "($line:$column)" init { diff --git a/library/src/commonMain/kotlin/net/sergeych/lyng/Script.kt b/library/src/commonMain/kotlin/net/sergeych/lyng/Script.kt index 5242ffb..f48e4b8 100644 --- a/library/src/commonMain/kotlin/net/sergeych/lyng/Script.kt +++ b/library/src/commonMain/kotlin/net/sergeych/lyng/Script.kt @@ -1,5 +1,6 @@ package net.sergeych.lyng +import kotlinx.coroutines.delay import kotlin.math.* class Script( @@ -53,6 +54,10 @@ class Script( raiseError(ObjAssertionError(this,"Assertion failed")) } + addVoidFn("delay") { + delay((this.args.firstAndOnly().toDouble()/1000.0).roundToLong()) + } + addConst("Real", ObjReal.type) addConst("String", ObjString.type) addConst("Int", ObjInt.type) diff --git a/library/src/commonTest/kotlin/ScriptTest.kt b/library/src/commonTest/kotlin/ScriptTest.kt index 9551a58..c46a4ea 100644 --- a/library/src/commonTest/kotlin/ScriptTest.kt +++ b/library/src/commonTest/kotlin/ScriptTest.kt @@ -1168,4 +1168,24 @@ class ScriptTest { """.trimIndent() ) } + + @Test + fun testSpoilArgsBug() = runTest { + eval( + """ + fun fnb(a,b) { a + b } + + fun fna(a, b) { + val a0 = a + val b0 = b + fnb(a + 1, b + 1) + assert( a0 == a ) + assert( b0 == b ) + } + + fna(5,6) + """ + ) + + } } \ No newline at end of file diff --git a/library/src/jvmTest/kotlin/BookTest.kt b/library/src/jvmTest/kotlin/BookTest.kt index aaf2a01..275d7be 100644 --- a/library/src/jvmTest/kotlin/BookTest.kt +++ b/library/src/jvmTest/kotlin/BookTest.kt @@ -5,8 +5,10 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.test.runTest import net.sergeych.lyng.Context import net.sergeych.lyng.ObjVoid +import java.nio.file.Files import java.nio.file.Files.readAllLines import java.nio.file.Paths +import kotlin.io.path.extension import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.fail @@ -29,10 +31,15 @@ data class DocTest( val code: String, val expectedOutput: String, val expectedResult: String, - val expectedError: String? = null + val expectedError: String? = null, + val bookMode: Boolean = false ) { val sourceLines by lazy { code.lines() } + val fileNamePart by lazy { + Paths.get(fileName).fileName.toString() + } + override fun toString(): String { return "DocTest:$fileName:${line + 1}..${line + sourceLines.size}" } @@ -40,14 +47,17 @@ data class DocTest( val detailedString by lazy { val codeWithLines = sourceLines.withIndex().map { (i, s) -> "${i + line + 1}: $s" }.joinToString("\n") var result = "$this\n$codeWithLines\n" - if (expectedOutput.isNotBlank()) - result += "--------expected output--------\n$expectedOutput\n" + if( !bookMode) { + if (expectedOutput.isNotBlank()) + result += "--------expected output--------\n$expectedOutput\n" - "$result-----expected return value-----\n$expectedResult" + "$result-----expected return value-----\n$expectedResult" + } + else result } } -fun parseDocTests(fileName: String): Flow = flow { +fun parseDocTests(fileName: String, bookMode: Boolean = false): Flow = flow { val book = readAllLines(Paths.get(fileName)) var startOffset = 0 val block = mutableListOf() @@ -74,6 +84,18 @@ fun parseDocTests(fileName: String): Flow = flow { } while (initial - leftMargin(x) != startOffset) block[i] = x } + if (bookMode) { + emit( + DocTest( + fileName, startIndex, + block.joinToString("\n"), + "", + "", + null, + bookMode = true + ) + ) + } // println(block.joinToString("\n") { "${startIndex + ii++}: $it" }) val outStart = block.indexOfFirst { it.startsWith(">>>") } if (outStart < 0) { @@ -134,13 +156,19 @@ fun parseDocTests(fileName: String): Flow = flow { } .flowOn(Dispatchers.IO) -suspend fun DocTest.test() { +suspend fun DocTest.test(context: Context = Context()) { val collectedOutput = StringBuilder() - val context = Context().apply { + val currentTest = this + context.apply { addFn("println") { - for ((i, a) in args.withIndex()) { - if (i > 0) collectedOutput.append(' '); collectedOutput.append(a.asStr.value) - collectedOutput.append('\n') + if( bookMode ) { + println("${currentTest.fileNamePart}:${currentTest.line}> ${args.joinToString(" "){it.asStr.value}}") + } + else { + for ((i, a) in args.withIndex()) { + if (i > 0) collectedOutput.append(' '); collectedOutput.append(a.asStr.value) + collectedOutput.append('\n') + } } ObjVoid } @@ -153,24 +181,39 @@ suspend fun DocTest.test() { null }?.inspect()?.replace(Regex("@\\d+"), "@...") - if (error != null || expectedOutput != collectedOutput.toString() || - expectedResult != result - ) { - println("Test failed: ${this.detailedString}") + if (bookMode) { + if (error != null) { + println("Sample failed: ${this.detailedString}") + fail("book sample failed", error) + } + } else { + if (error != null || expectedOutput != collectedOutput.toString() || + expectedResult != result + ) { + println("Test failed: ${this.detailedString}") + } + error?.let { + fail("test failed", it) + } + assertEquals(expectedOutput, collectedOutput.toString(), "script output do not match") + assertEquals(expectedResult, result.toString(), "script result does not match") + // println("OK: $this") } - error?.let { - fail(it.toString(), it) - } - assertEquals(expectedOutput, collectedOutput.toString(), "script output do not match") - assertEquals(expectedResult, result.toString(), "script result does not match") - // println("OK: $this") } -suspend fun runDocTests(fileName: String) { - parseDocTests(fileName).collect { dt -> - dt.test() +suspend fun runDocTests(fileName: String, bookMode: Boolean = false) { + val bookContext = Context() + var count = 0 + parseDocTests(fileName, bookMode).collect { dt -> + if (bookMode) dt.test(bookContext) + else dt.test() + count++ } - + print("completed mdtest: $fileName; ") + if (bookMode) + println("fragments processed: $count") + else + println("tests passed: $count") } class BookTest { @@ -210,4 +253,12 @@ class BookTest { runDocTests("../docs/Range.md") } + @Test + fun testSampleBooks() = runTest { + for (bt in Files.list(Paths.get("../docs/samples")).toList()) { + if (bt.extension == "md") { + runDocTests(bt.toString(), bookMode = true) + } + } + } } \ No newline at end of file diff --git a/library/src/jvmTest/kotlin/SamplesTest.kt b/library/src/jvmTest/kotlin/SamplesTest.kt new file mode 100644 index 0000000..2ab11ac --- /dev/null +++ b/library/src/jvmTest/kotlin/SamplesTest.kt @@ -0,0 +1,35 @@ +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock +import net.sergeych.lyng.Context +import java.nio.file.Files +import java.nio.file.Paths +import kotlin.io.path.extension +import kotlin.test.Test + +suspend fun executeSampleTests(fileName: String) { + val sample = withContext(Dispatchers.IO) { + Files.readString(Paths.get(fileName)) + } + runBlocking { + val c = Context() + for( i in 1..1) { + val start = Clock.System.now() + c.eval(sample) + val time = Clock.System.now() - start + println("$time: $fileName") +// delay(100) + } + } +} + +class SamplesTest { + + @Test + fun testSamples() = runBlocking { + for (s in Files.list(Paths.get("../docs/samples"))) { + if (s.extension == "lyng") executeSampleTests(s.toString()) + } + } +} \ No newline at end of file