docs+doctest, while loop with break and labels for non-local breaks

This commit is contained in:
sergeych committed 2025-05-20 16:31:22 +04:00
1 parent 0569be3b21
commit 22fbd5584b
12 files changed
+729 -105

No files matched your search

+177
View File
@@ -0,0 +1,177 @@
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.test.runTest
import net.sergeych.ling.Context
import net.sergeych.ling.ObjVoid
import java.nio.file.Files.readAllLines
import java.nio.file.Paths
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.fail
fun leftMargin(s: String): Int {
var cnt = 0
for (c in s) {
when (c) {
' ' -> cnt++
'\t' -> cnt = (cnt / 4.0 + 0.9).toInt() * 4
else -> break
}
}
return cnt
}
data class DocTest(
val fileName: String,
val line: Int,
val code: String,
val expectedOutput: String,
val expectedResult: String,
val expectedError: String? = null
) {
val sourceLines by lazy { code.lines() }
override fun toString(): String {
return "DocTest:$fileName:${line+1}..${line + sourceLines.size}"
}
val detailedString by lazy {
val codeWithLines = sourceLines.withIndex().map { (i, s) -> "${i + line}: $s" }.joinToString("\n")
"$this\n" +
codeWithLines + "\n" +
"--------expected output--------\n" +
expectedOutput +
"-----expected return value-----\n" +
expectedResult
}
}
fun parseDocTests(name: String): Flow<DocTest> = flow {
val book = readAllLines(Paths.get("../docs/tutorial.md"))
var startOffset = 0
val block = mutableListOf<String>()
var startIndex = 0
for ((index, l) in book.withIndex()) {
val off = leftMargin(l)
when {
off < startOffset && startOffset != 0 -> {
if (l.isBlank()) {
continue
}
// end of block or just text:
if (block.isNotEmpty()) {
// check/create block
// 2 lines min
if (block.size > 1) {
// remove prefix
for ((i, s) in block.withIndex()) {
var x = s
// could be tabs :(
val initial = leftMargin(x)
do {
x = x.drop(1)
} while (initial - leftMargin(x) != startOffset)
block[i] = x
}
// println(block.joinToString("\n") { "${startIndex + ii++}: $it" })
val outStart = block.indexOfFirst { it.startsWith(">>>") }
if (outStart < 0) {
// println("No output at block from line ${startIndex+1}")
} else {
var isValid = true
val result = mutableListOf<String>()
while (block.size > outStart) {
val line = block.removeAt(outStart)
if (!line.startsWith(">>> ")) {
println("invalid output line, must start with '>>> ', block from ${startIndex + 1}: $line")
isValid = false
break
}
result.add(line.drop(4))
}
if (isValid) {
emit(
DocTest(
name, startIndex,
block.joinToString("\n"),
if (result.size > 1)
result.dropLast(1).joinToString { it + "\n" }
else "",
result.last()
)
)
}
}
// last line '>>>'
}
block.clear()
startOffset = 0
}
}
off != 0 && startOffset == 0 -> {
// start
block.clear()
startIndex = index
block.add(l)
startOffset = off
}
off != 0 -> {
block.add(l)
}
off == 0 && startOffset == 0 -> {
// skip
}
else -> {
throw RuntimeException("Unexpected line: ($off/$startOffset) $l")
}
}
}
}
.flowOn(Dispatchers.IO)
suspend fun DocTest.test() {
val collectedOutput = StringBuilder()
val context = Context().apply {
addFn("println") {
for ((i, a) in args.withIndex()) {
if (i > 0) collectedOutput.append(' '); collectedOutput.append(a)
collectedOutput.append('\n')
}
ObjVoid
}
}
var error: Throwable? = null
val result = try {
context.eval(code)
}
catch (e: Throwable) {
error = e
null
}?.toString()?.replace(Regex("@\\d+"), "@...")
if (error != null || expectedOutput != collectedOutput.toString() ||
expectedResult != result
) {
println("Test failed: ${this.detailedString}")
}
error?.let { fail(it.toString()) }
assertEquals(expectedOutput, collectedOutput.toString(), "script output do not match")
assertEquals(expectedResult, result.toString(), "script result does not match")
// println("OK: $this")
}
class BookTest {
@Test
fun testsFromTutorial() = runTest {
parseDocTests("../docs/tutorial.md").collect { dt ->
dt.test()
}
}
}