Phase 4: add structure, folding, and references

This commit is contained in:
hgranthorner
2026-08-04 14:49:03 -04:00
parent bb54639ce5
commit c4e1d92c9a
16 changed files with 826 additions and 14 deletions

View File

@@ -3,6 +3,8 @@ package dev.hgh.jai.editor
import com.intellij.codeInsight.highlighting.BraceMatchingUtil
import com.intellij.lang.LanguageBraceMatching
import com.intellij.lang.LanguageCommenters
import com.intellij.lang.LanguageStructureViewBuilder
import com.intellij.lang.folding.LanguageFolding
import com.intellij.openapi.editor.ex.EditorEx
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import dev.hgh.jai.JaiFileType
@@ -22,6 +24,24 @@ class JaiEditorSupportTest : BasePlatformTestCase() {
assertTrue("expected JaiBraceMatcher, got $matcher", matcher is JaiBraceMatcher)
}
fun testFoldingBuilderIsRegistered() {
val foldingBuilders = LanguageFolding.INSTANCE.allForLanguage(JaiLanguage)
assertTrue(
"expected JaiFoldingBuilder in $foldingBuilders",
foldingBuilders.any { it is JaiFoldingBuilder },
)
}
fun testStructureViewFactoryIsRegistered() {
val file = myFixture.configureByText("structure.jai", "value := 1;")
val builder = LanguageStructureViewBuilder.getInstance().getStructureViewBuilder(file)
assertNotNull("expected a Jai structure-view builder", builder)
assertTrue(
"expected a TreeBasedStructureViewBuilder, got $builder",
builder is com.intellij.ide.structureView.TreeBasedStructureViewBuilder,
)
}
fun testMatchesOrdinaryBraces() {
assertMatchForward("main :: () |{ x := 1; }", "main :: () { x := 1; |}")
}

View File

@@ -0,0 +1,65 @@
package dev.hgh.jai.editor
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import dev.hgh.jai.lexer.JaiTokenTypes
class JaiFoldingBuilderTest : BasePlatformTestCase() {
fun testFoldsMultilineBlocksAndComments() {
val text =
"""
main :: () {
value := 1;
}
/* a comment
* with two lines
*/
""".trimIndent()
val file = myFixture.configureByText("folding.jai", text)
val descriptors = JaiFoldingBuilder().buildFoldRegions(file, myFixture.editor.document, quick = false)
val foldedText =
descriptors.map { descriptor ->
text.substring(descriptor.range.startOffset, descriptor.range.endOffset)
}
assertEquals(2, descriptors.size)
assertTrue(foldedText.any { it.contains("value := 1;") })
assertTrue(foldedText.any { it.contains("with two lines") })
}
fun testFoldsNestedCommentsAndHereStrings() {
val text =
"""
/* outer comment
/* nested comment */
still in the outer comment
*/
body :: #string END
raw here-string text
END;
""".trimIndent()
val file = myFixture.configureByText("literals.jai", text)
val builder = JaiFoldingBuilder()
val descriptors = builder.buildFoldRegions(file, myFixture.editor.document, quick = false)
val foldedText =
descriptors.map { descriptor ->
text.substring(descriptor.range.startOffset, descriptor.range.endOffset)
}
assertEquals(2, descriptors.size)
assertTrue(foldedText.any { it.contains("nested comment") })
assertTrue(foldedText.any { it.contains("raw here-string text") })
val hereString = descriptors.first { it.element.elementType == JaiTokenTypes.HERE_STRING }
assertEquals("#string …", builder.getPlaceholderText(hereString.element))
}
fun testDoesNotFoldSingleLineRegions() {
val text = "main :: () { value := 1; } /* one line */"
val file = myFixture.configureByText("single-line.jai", text)
val descriptors = JaiFoldingBuilder().buildFoldRegions(file, myFixture.editor.document, quick = false)
assertEmpty(descriptors.toList())
}
}

View File

@@ -0,0 +1,101 @@
package dev.hgh.jai.reference
import com.intellij.openapi.util.TextRange
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.psi.PsiFile
import com.intellij.psi.PsiManager
import com.intellij.psi.PsiReference
import com.intellij.psi.PsiReferenceService
import com.intellij.psi.util.PsiTreeUtil
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import dev.hgh.jai.psi.JaiLiteralExpr
class JaiReferenceTest : BasePlatformTestCase() {
fun testLoadResolvesAFileRelativeToTheSource() {
val target = myFixture.addFileToProject("other.jai", "value := 1;")
val source = myFixture.addFileToProject("main.jai", "#load \"other.jai\";")
val literal = literalWithText(source, "\"other.jai\"")
val reference = onlyReference(literal)
val resolved = reference.resolve() as? PsiFile
assertNotNull("#load should resolve its sibling file", resolved)
assertEquals(target.virtualFile.path, resolved!!.virtualFile.path)
assertEquals(TextRange(1, literal.textLength - 1), reference.rangeInElement)
}
fun testImportResolvesAJaiModuleDirectory() {
val sourceVirtualFile =
LocalFileSystem
.getInstance()
.findFileByPath("${System.getProperty("user.home")}/.local/jai/how_to/001_first.jai")
assertNotNull("expected the local Jai corpus", sourceVirtualFile)
val source = PsiManager.getInstance(project).findFile(sourceVirtualFile!!)
assertNotNull("expected PSI for ${sourceVirtualFile.path}", source)
val literal =
PsiTreeUtil
.findChildrenOfType(source, JaiLiteralExpr::class.java)
.firstOrNull { it.text == "\"Basic\"" }
assertNotNull("expected #import \"Basic\" in ${sourceVirtualFile.path}", literal)
val resolved = onlyReference(literal!!).resolve() as? PsiFile
val expectedPath = "${System.getProperty("user.home")}/.local/jai/modules/Basic/module.jai"
assertNotNull("#import should resolve a Jai module directory", resolved)
assertEquals(expectedPath, resolved!!.virtualFile.path)
}
fun testExplicitImportModesUseTheContainingDirectory() {
val source = localPsiFile("how_to/040_import_and_load/main.jai")
val fileReference = onlyReference(literalWithText(source, "\"files/specific.jai\""))
val directoryReference = onlyReference(literalWithText(source, "\"files/From_Subdirectory\""))
assertEquals(
"${System.getProperty("user.home")}/.local/jai/how_to/040_import_and_load/files/specific.jai",
(fileReference.resolve() as PsiFile).virtualFile.path,
)
assertEquals(
"${System.getProperty("user.home")}/.local/jai/how_to/040_import_and_load/files/From_Subdirectory/module.jai",
(directoryReference.resolve() as PsiFile).virtualFile.path,
)
}
fun testImportStringIsNotTreatedAsAFileReference() {
val source = myFixture.addFileToProject("main.jai", "#import,string \"inline code\";")
val literal = literalWithText(source, "\"inline code\"")
assertTrue(references(literal).isEmpty())
}
private fun localPsiFile(relativePath: String): PsiFile {
val virtualFile =
LocalFileSystem
.getInstance()
.findFileByPath("${System.getProperty("user.home")}/.local/jai/$relativePath")
assertNotNull("expected the local Jai corpus", virtualFile)
val psiFile = PsiManager.getInstance(project).findFile(virtualFile!!)
assertNotNull("expected PSI for ${virtualFile.path}", psiFile)
return psiFile!!
}
private fun literalWithText(
source: PsiFile,
text: String,
): JaiLiteralExpr {
val literal =
PsiTreeUtil
.findChildrenOfType(source, JaiLiteralExpr::class.java)
.firstOrNull { it.text == text }
assertNotNull("expected string literal $text in ${source.virtualFile.path}", literal)
return literal!!
}
private fun onlyReference(literal: JaiLiteralExpr): PsiReference {
val references = references(literal)
assertEquals("expected exactly one path reference", 1, references.size)
return references.single()
}
private fun references(literal: JaiLiteralExpr): List<PsiReference> =
PsiReferenceService.getService().getContributedReferences(literal).toList()
}

View File

@@ -0,0 +1,64 @@
package dev.hgh.jai.structure
import com.intellij.ide.structureView.StructureViewTreeElement
import com.intellij.ide.structureView.TreeBasedStructureViewBuilder
import com.intellij.psi.PsiElement
import com.intellij.testFramework.fixtures.BasePlatformTestCase
class JaiStructureViewTest : BasePlatformTestCase() {
fun testListsTopLevelAndNestedDeclarationsInSourceOrder() {
val file =
myFixture.configureByText(
"structure.jai",
"""
main :: () {
helper :: () {}
}
Point :: struct {
x: int;
}
operator + :: (a: Point, b: Point) -> Point {}
""".trimIndent(),
)
val model = JaiStructureViewModel(null, file)
val root: StructureViewTreeElement = model.getRoot()
assertEquals(listOf("main", "Point", "operator +"), children(root).map(::label))
val main = children(root)[0]
assertEquals(listOf("helper"), children(main).map(::label))
assertEquals(0, (main.getValue() as PsiElement).textOffset)
}
fun testOnlyDirectDeclarationsBecomeChildren() {
val file =
myFixture.configureByText(
"nested.jai",
"""
main :: () {
if true {
hidden :: () {}
}
visible :: () {}
}
""".trimIndent(),
)
val root: StructureViewTreeElement = JaiStructureViewModel(null, file).getRoot()
assertEquals(listOf("visible"), children(children(root)[0]).map(::label))
}
fun testFactoryCreatesAUsableStructureModel() {
val file = myFixture.configureByText("empty.jai", "value := 1;")
val builder = JaiStructureViewFactory().getStructureViewBuilder(file) as TreeBasedStructureViewBuilder
val model = builder.createStructureViewModel(null)
val root: StructureViewTreeElement = model.getRoot()
assertEquals("empty.jai", label(root))
assertEquals(listOf("value"), children(root).map(::label))
}
private fun children(element: StructureViewTreeElement): List<StructureViewTreeElement> =
element.getChildren().map { it as StructureViewTreeElement }
private fun label(element: StructureViewTreeElement): String = element.getPresentation().presentableText ?: ""
}