Tier 2 golden trees, and #scope_file is a statement not a modifier

isDeclarationAhead used to skip *any* directive, so '#scope_file' was absorbed as
a modifier of the declaration after it. That misreports scope, since a scope
directive governs everything that follows it. Only #as, #overlay, #add_context
and #no_reset actually prefix a declaration in the corpus; the rest are
statements now, which the regenerated Directives.txt shows.

Golden fixtures cover the reference doc's gotcha list: declaration forms,
procedures (named/multiple/variadic/polymorphic/foreign returns), structs, enums,
unions, array and pointer types, the 'if x == { case }' switch form, for
modifiers, ifx, and directives including a here-string.

DebugParseTest is now inert by default and documented as the scratch loop.

generateParser wipes its output through a Delete task rather than a doFirst,
because a lambda added from a Kotlin build script captures the script object and
breaks the configuration cache ('./jaigradle check' failed to store it).

check and verifyPlugin (IU-253/261/262) both green.
This commit is contained in:
hgranthorner
2026-08-04 12:48:30 -04:00
parent 47a2c5805e
commit e3ff13f9f3
20 changed files with 1050 additions and 221 deletions

View File

@@ -1,7 +1,6 @@
package dev.hgh.jai.parser
import com.intellij.psi.PsiErrorElement
import com.intellij.psi.PsiFile
import com.intellij.psi.PsiFileFactory
import com.intellij.psi.PsiRecursiveElementWalkingVisitor
import com.intellij.testFramework.fixtures.BasePlatformTestCase
@@ -9,70 +8,77 @@ import dev.hgh.jai.JaiLanguage
import java.io.File
/**
* Scratch harness for grammar work. Not a gate — the gate is [JaiCorpusParserTest].
* Edit [snippets] and [corpusFiles] freely while iterating on `Jai.bnf`.
* Scratch harness for grammar work. **Not a gate** — the gate is [JaiCorpusParserTest].
*
* Both lists are empty on purpose, so this is silent until someone is working on
* `Jai.bnf`. To iterate: paste failing snippets into [snippets] or corpus-relative
* paths into [corpusFiles], run `./jaitest`, and read the printed first error. Add
* `-Djai.debug.tree=1` to dump the PSI tree as well.
*
* The loop this supports is: read the sample errors printed by [JaiCorpusParserTest],
* reduce one to a snippet here, fix the grammar, then re-check the corpus percentage.
*/
class DebugParseTest : BasePlatformTestCase() {
private val snippets =
listOf(
"T :: struct {\n p := .06;\n b: BG;\n b.shape.kind = .ABS;\n b.color = V4.{.00, .10, 1};\n}\n",
"T :: struct {\n orientation: enum u8 {\n H :: 0;\n V :: 1;\n }\n\n d: BT;\n}\n",
"T :: struct {\n using,except .[\"x\"] orientation: Quaternion;\n}\n",
"operator *[] :: (b: *Bucket, index: int) -> *int { return null; }\n",
"code :: #code a := Vector3.{1, 1, 1};\n",
)
/** Jai source snippets to parse. */
private val snippets = emptyList<String>()
/** Corpus files to dump the first error of. */
private val corpusFiles =
listOf(
"modules/GetRect/widgets/color_picker.jai",
"modules/GetRect/widgets/slidable_region.jai",
"how_to/094_array_operators.jai",
"how_to/044_using_advanced/main.jai",
"how_to/630_compiler_get_nodes.jai",
"modules/Bucket_Array.jai",
"modules/Android/Toolchain/adb.jai",
"modules/Hash_Table.jai",
)
/** Paths under `~/.local/jai` to parse. */
private val corpusFiles = emptyList<String>()
fun testSnippets() {
val factory = PsiFileFactory.getInstance(project)
for (text in snippets) {
val file = factory.createFileFromText("scratch.jai", JaiLanguage, text)
val error = errorsIn(file).firstOrNull()
val label = text.replace("\n", "\\n")
if (error == null) {
println("ok $label")
} else {
println("FAIL $label")
println(" ${error.errorDescription} | at '${around(text, error.textOffset)}'")
}
if (System.getProperty("jai.debug.tree") != null) {
println(
com.intellij.psi.impl.DebugUtil
.psiToString(file, true, false),
)
}
}
}
fun testCorpusFiles() {
val root = File(System.getProperty("user.home"), ".local/jai")
if (!root.exists()) return
for (relative in corpusFiles) {
val file = File(root, relative)
if (file.exists()) dumpFile(file) else println("missing: $relative")
}
}
fun testSnippets() {
val factory = PsiFileFactory.getInstance(project)
var failures = 0
for (text in snippets) {
val file = factory.createFileFromText("t.jai", JaiLanguage, text)
val error = firstError(file)
if (error == null) {
println("ok ${text.replace("\n", "\\n")}")
if (System.getProperty("jai.debug.tree") != null) {
println(com.intellij.psi.impl.DebugUtil.psiToString(file, true, false))
}
} else {
failures++
val at = text.substring(error.textOffset).take(28).replace("\n", "\\n")
println("FAIL ${text.replace("\n", "\\n")}")
println(" ${error.errorDescription} | at '$at'")
if (System.getProperty("jai.debug.tree") != null) {
println(com.intellij.psi.impl.DebugUtil.psiToString(file, true, false))
}
if (!file.exists()) {
println("missing: $relative")
continue
}
val text = file.readText()
val psi = PsiFileFactory.getInstance(project).createFileFromText(file.name, JaiLanguage, text)
val errors = errorsIn(psi)
if (errors.isEmpty()) {
println("$relative: clean")
continue
}
println("$relative: ${errors.size} error(s)")
for (error in errors.take(REPORTED_ERRORS)) {
val line = text.substring(0, error.textOffset).count { it == '\n' } + 1
println(" :$line ${error.errorDescription}")
println(" at '${around(text, error.textOffset)}'")
}
}
println("$failures/${snippets.size} snippets failed")
}
private fun dumpFile(file: File) {
val text = file.readText()
val psi = PsiFileFactory.getInstance(project).createFileFromText(file.name, JaiLanguage, text)
private fun around(
text: String,
offset: Int,
): String = text.substring(offset).take(SNIPPET_WIDTH).replace("\n", "\\n")
private fun errorsIn(file: com.intellij.psi.PsiFile): List<PsiErrorElement> {
val errors = mutableListOf<PsiErrorElement>()
psi.accept(
file.accept(
object : PsiRecursiveElementWalkingVisitor() {
override fun visitErrorElement(element: PsiErrorElement) {
errors.add(element)
@@ -80,28 +86,11 @@ class DebugParseTest : BasePlatformTestCase() {
}
},
)
if (errors.isEmpty()) {
println("${file.name}: clean")
return
}
println("${file.name}: ${errors.size} error(s)")
for (error in errors.take(6)) {
val line = text.substring(0, error.textOffset).count { it == '\n' } + 1
println(" :$line ${error.errorDescription}")
println(" at '${text.substring(error.textOffset).take(70).replace("\n", "\\n")}'")
}
return errors
}
private fun firstError(file: PsiFile): PsiErrorElement? {
var first: PsiErrorElement? = null
file.accept(
object : PsiRecursiveElementWalkingVisitor() {
override fun visitErrorElement(element: PsiErrorElement) {
if (first == null) first = element
super.visitErrorElement(element)
}
},
)
return first
private companion object {
const val REPORTED_ERRORS = 6
const val SNIPPET_WIDTH = 70
}
}

View File

@@ -1,11 +1,24 @@
package dev.hgh.jai.parser
/**
* Tier 2 (docs/BUILD_PLAN.md §3): golden PSI trees for the constructs listed as
* gotchas in `docs/JAI_LANGUAGE_REFERENCE.md` §14.
*
* The corpus gate proves the grammar *accepts* real code; these prove it builds the
* tree we intend, so a refactor that quietly reshapes the PSI shows up as a diff.
*
* `ParsingTestCase` writes the expected `.txt` when it is missing, which makes adding
* a case cheap — but the generated tree must be read before committing, or the test
* asserts nothing.
*/
class JaiParserGoldenTest : JaiParsingTestCase() {
fun testSimpleDeclarations() {
doTest(true)
}
fun testDeclarations() = doTest(true)
fun testExpressions() {
doTest(true)
}
fun testProcedures() = doTest(true)
fun testTypes() = doTest(true)
fun testControlFlow() = doTest(true)
fun testDirectives() = doTest(true)
}