fix #5 loop optimization
fixed arguments modifying bug added samples and samplebooks w/tests
This commit is contained in:
parent
53eb1bc5e7
commit
512dda5984
44
docs/samples/combinatorics.lyng.md
Normal file
44
docs/samples/combinatorics.lyng.md
Normal file
@ -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...
|
||||
|
||||
|
19
docs/samples/happy_numbers.lyng
Normal file
19
docs/samples/happy_numbers.lyng
Normal file
@ -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 )
|
@ -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
|
||||
|
@ -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" }
|
||||
|
@ -72,6 +72,9 @@ android {
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
implementation(libs.firebase.crashlytics.buildtools)
|
||||
}
|
||||
|
||||
mavenPublishing {
|
||||
publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL)
|
||||
|
@ -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,6 +704,7 @@ class Compiler(
|
||||
var index = 0
|
||||
while (true) {
|
||||
loopSO.value = current
|
||||
if (canBreak) {
|
||||
try {
|
||||
result = body.execute(forContext)
|
||||
} catch (lbe: LoopBreakContinueException) {
|
||||
@ -716,6 +718,8 @@ class Compiler(
|
||||
} else
|
||||
throw lbe
|
||||
}
|
||||
}
|
||||
else result = body.execute(forContext)
|
||||
if (++index >= size) break
|
||||
current = sourceObj.getAt(forContext, index)
|
||||
}
|
||||
@ -734,11 +738,13 @@ 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()) {
|
||||
if (catchBreak)
|
||||
try {
|
||||
loopVar.value = iterObj.invokeInstanceMethod(forContext, "next")
|
||||
result = body.execute(forContext)
|
||||
@ -748,6 +754,10 @@ class Compiler(
|
||||
}
|
||||
return lbe.result
|
||||
}
|
||||
else {
|
||||
loopVar.value = iterObj.invokeInstanceMethod(forContext, "next")
|
||||
result = body.execute(forContext)
|
||||
}
|
||||
}
|
||||
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)
|
||||
|
@ -3,15 +3,31 @@ package net.sergeych.lyng
|
||||
internal class CompilerContext(val tokens: List<Token>) {
|
||||
val labels = mutableSetOf<String>()
|
||||
|
||||
var breakFound = false
|
||||
private set
|
||||
|
||||
var loopLevel = 0
|
||||
private set
|
||||
|
||||
inline fun <T> parseLoop(f: () -> T): Pair<Boolean,T> {
|
||||
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 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<Token>) {
|
||||
* @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<Token>) {
|
||||
|
||||
@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<Token>) {
|
||||
}
|
||||
}
|
||||
|
||||
inline fun addBreak() {
|
||||
breakFound = true
|
||||
}
|
||||
|
||||
}
|
@ -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) }
|
||||
}
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
"""
|
||||
)
|
||||
|
||||
}
|
||||
}
|
@ -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( !bookMode) {
|
||||
if (expectedOutput.isNotBlank())
|
||||
result += "--------expected output--------\n$expectedOutput\n"
|
||||
|
||||
"$result-----expected return value-----\n$expectedResult"
|
||||
}
|
||||
else result
|
||||
}
|
||||
}
|
||||
|
||||
fun parseDocTests(fileName: String): Flow<DocTest> = flow {
|
||||
fun parseDocTests(fileName: String, bookMode: Boolean = false): Flow<DocTest> = flow {
|
||||
val book = readAllLines(Paths.get(fileName))
|
||||
var startOffset = 0
|
||||
val block = mutableListOf<String>()
|
||||
@ -74,6 +84,18 @@ fun parseDocTests(fileName: String): Flow<DocTest> = 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,14 +156,20 @@ fun parseDocTests(fileName: String): Flow<DocTest> = 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") {
|
||||
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 (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(it.toString(), it)
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
35
library/src/jvmTest/kotlin/SamplesTest.kt
Normal file
35
library/src/jvmTest/kotlin/SamplesTest.kt
Normal file
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user