Phase 5a: add configurable Jai module roots

This commit is contained in:
hgranthorner
2026-08-05 10:24:32 -04:00
parent 9122b5a5c0
commit 2bc1b9f4be
13 changed files with 740 additions and 214 deletions

View File

@@ -7,7 +7,6 @@ import com.intellij.codeInsight.completion.CompletionResultSet
import com.intellij.codeInsight.completion.CompletionType
import com.intellij.codeInsight.lookup.LookupElementBuilder
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.patterns.PlatformPatterns
import com.intellij.psi.tree.IElementType
@@ -15,6 +14,8 @@ import com.intellij.psi.util.PsiTreeUtil
import com.intellij.util.ProcessingContext
import dev.hgh.jai.lexer.JaiLexer
import dev.hgh.jai.lexer.JaiTokenTypes
import dev.hgh.jai.module.JaiModuleResolver
import dev.hgh.jai.module.JaiPathMode
import dev.hgh.jai.psi.JaiDirectiveExpr
/** Directives and their adjacent comma flags before an import/load string. */
@@ -106,11 +107,12 @@ private class JaiCompletionProvider : CompletionProvider<CompletionParameters>()
resultSet: CompletionResultSet,
) {
val sourceFile = parameters.originalFile.virtualFile
val project = parameters.originalFile.project
val variants =
if (directive == "#import") {
JaiModuleCompletion.moduleNames(parameters.originalFile.project, sourceFile)
JaiModuleCompletion.moduleNames(project, sourceFile)
} else {
JaiModuleCompletion.loadPaths(sourceFile)
JaiModuleCompletion.loadPaths(project, sourceFile)
}
variants
.filter { it.startsWith(prefix) }
@@ -309,14 +311,12 @@ private object JaiCompletionCatalog {
private object JaiModuleCompletion {
private const val JAI_EXTENSION = "jai"
private val fileSystem: LocalFileSystem
get() = LocalFileSystem.getInstance()
fun moduleNames(
project: com.intellij.openapi.project.Project,
sourceFile: VirtualFile?,
): List<String> =
collectCandidates(candidateRoots(project, sourceFile, moduleMode = true)) { child ->
collectCandidates(JaiModuleResolver.candidateRoots(project, sourceFile, JaiPathMode.MODULE)) { child ->
when {
child.isDirectory && child.findChild("module.jai") != null -> child.name
!child.isDirectory && child.extension == JAI_EXTENSION -> child.nameWithoutExtension
@@ -324,10 +324,11 @@ private object JaiModuleCompletion {
}
}
fun loadPaths(sourceFile: VirtualFile?): List<String> =
collectCandidates(
listOfNotNull(sourceFile?.parent),
) { child ->
fun loadPaths(
project: com.intellij.openapi.project.Project,
sourceFile: VirtualFile?,
): List<String> =
collectCandidates(JaiModuleResolver.candidateRoots(project, sourceFile, JaiPathMode.FILE)) { child ->
if (!child.isDirectory && child.extension == JAI_EXTENSION) child.name else null
}
@@ -344,27 +345,4 @@ private object JaiModuleCompletion {
}
return names.sorted()
}
private fun candidateRoots(
project: com.intellij.openapi.project.Project,
sourceFile: VirtualFile?,
moduleMode: Boolean,
): List<VirtualFile> {
val roots = linkedMapOf<String, VirtualFile>()
fun add(root: VirtualFile?) {
if (root != null && root.isValid && root.isDirectory) roots.putIfAbsent(root.path, root)
}
if (moduleMode) {
add(sourceFile?.parent?.findChild("modules"))
val projectRoot = project.basePath?.let(fileSystem::findFileByPath)
add(projectRoot?.findChild("modules"))
add(projectRoot)
add(fileSystem.findFileByPath("${System.getProperty("user.home")}/.local/jai/modules"))
} else {
add(sourceFile?.parent)
}
return roots.values.toList()
}
}

View File

@@ -0,0 +1,163 @@
package dev.hgh.jai.module
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.openapi.vfs.VirtualFile
import dev.hgh.jai.settings.JaiProjectSettings
/** The information needed to resolve an #import or #load string. */
data class JaiModuleTarget(
val directive: String,
val path: String,
val flags: Set<String>,
)
enum class JaiPathMode {
MODULE,
FILE,
DIRECTORY,
RELATIVE,
}
/** Resolves Jai paths and exposes the same root order to completion. */
object JaiModuleResolver {
private val fileSystem: LocalFileSystem
get() = LocalFileSystem.getInstance()
fun resolve(
project: Project,
sourceFile: VirtualFile,
target: JaiModuleTarget,
): VirtualFile? {
val rawPath = target.path.replace('\\', '/')
val path = normalize(rawPath)
if (path.isEmpty()) return null
val mode =
when {
target.directive == "#load" || "file" in target.flags -> JaiPathMode.FILE
"dir" in target.flags -> JaiPathMode.DIRECTORY
rawPath.startsWith("./") || rawPath.startsWith("../") -> JaiPathMode.RELATIVE
else -> JaiPathMode.MODULE
}
if (path.startsWith('/')) {
return findAbsolute(path, mode)
}
for (root in candidateRoots(project, sourceFile, mode)) {
findInRoot(root, path, mode)?.let { return it }
}
return null
}
/**
* Returns roots in resolution precedence order. Configured roots are intentionally shared by
* the resolver and completion so a path cannot resolve differently from the path offered by
* completion.
*/
fun candidateRoots(
project: Project,
sourceFile: VirtualFile?,
mode: JaiPathMode,
): List<VirtualFile> {
val roots = linkedMapOf<String, VirtualFile>()
fun add(root: VirtualFile?) {
if (root != null && root.isValid && root.isDirectory) {
roots.putIfAbsent(root.path, root)
}
}
val sourceRoot = sourceFile?.parent
when (mode) {
JaiPathMode.MODULE -> add(sourceRoot?.findChild("modules"))
JaiPathMode.FILE, JaiPathMode.DIRECTORY, JaiPathMode.RELATIVE -> add(sourceRoot)
}
// A configured root is an explicit opt-in to searching outside the project. For direct
// #load/#import paths it is searched after the containing directory; for bare module
// imports it wins over the built-in/project fallbacks below.
if (mode != JaiPathMode.RELATIVE) {
JaiProjectSettings.getInstance(project).configuredRoots().forEach(::add)
}
if (mode == JaiPathMode.MODULE) {
val projectRoot = project.basePath?.let(fileSystem::findFileByPath)
add(projectRoot?.findChild("modules"))
add(projectRoot)
add(
fileSystem.findFileByPath(
"${System.getProperty("user.home")}/.local/jai/modules",
),
)
}
return roots.values.toList()
}
private fun findAbsolute(
path: String,
mode: JaiPathMode,
): VirtualFile? {
val direct = fileSystem.findFileByPath(path)
if (direct != null) {
when (mode) {
JaiPathMode.FILE -> {
if (!direct.isDirectory) return direct
}
JaiPathMode.DIRECTORY -> {
if (direct.isDirectory) return direct.findChild("module.jai")
}
JaiPathMode.RELATIVE -> {
return if (direct.isDirectory) direct.findChild("module.jai") else direct
}
JaiPathMode.MODULE -> {
if (!direct.isDirectory) return direct
direct.findChild("module.jai")?.let { return it }
}
}
}
if (mode == JaiPathMode.MODULE && !path.endsWith(".jai")) {
return fileSystem.findFileByPath("$path.jai")
?: fileSystem.findFileByPath("$path/module.jai")
}
return null
}
private fun findInRoot(
root: VirtualFile,
path: String,
mode: JaiPathMode,
): VirtualFile? {
val direct = root.findFileByRelativePath(path)
if (direct != null) {
when (mode) {
JaiPathMode.FILE -> {
if (!direct.isDirectory) return direct
}
JaiPathMode.DIRECTORY -> {
if (direct.isDirectory) return direct.findChild("module.jai")
}
JaiPathMode.RELATIVE -> {
return if (direct.isDirectory) direct.findChild("module.jai") else direct
}
JaiPathMode.MODULE -> {
if (!direct.isDirectory) return direct
direct.findChild("module.jai")?.let { return it }
}
}
}
if (mode == JaiPathMode.MODULE && !path.endsWith(".jai")) {
return root.findFileByRelativePath("$path.jai")
?: root.findFileByRelativePath("$path/module.jai")
}
return null
}
private fun normalize(path: String): String = path.replace('\\', '/').removePrefix("./")
}

View File

@@ -1,9 +1,6 @@
package dev.hgh.jai.reference
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.TextRange
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.patterns.PlatformPatterns
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiFile
@@ -17,6 +14,8 @@ import com.intellij.psi.PsiReferenceService
import com.intellij.psi.util.PsiTreeUtil
import com.intellij.util.ProcessingContext
import dev.hgh.jai.lexer.JaiTokenTypes
import dev.hgh.jai.module.JaiModuleResolver
import dev.hgh.jai.module.JaiModuleTarget
import dev.hgh.jai.psi.JaiBlock
import dev.hgh.jai.psi.JaiDeclName
import dev.hgh.jai.psi.JaiDeclaration
@@ -219,19 +218,6 @@ private object JaiSymbolResolver {
}
}
private data class JaiModuleTarget(
val directive: String,
val path: String,
val flags: Set<String>,
)
private enum class JaiPathMode {
MODULE,
FILE,
DIRECTORY,
RELATIVE,
}
private class JaiModuleReference(
private val sourceElement: PsiElement,
rangeInElement: TextRange,
@@ -247,137 +233,6 @@ private class JaiModuleReference(
override fun getVariants(): Array<Any> = emptyArray()
}
/** Resolves paths without requiring an external compiler or an IDE index. */
private object JaiModuleResolver {
private val fileSystem: LocalFileSystem
get() = LocalFileSystem.getInstance()
fun resolve(
project: Project,
sourceFile: VirtualFile,
target: JaiModuleTarget,
): VirtualFile? {
val rawPath = target.path.replace('\\', '/')
val path = normalize(rawPath)
if (path.isEmpty()) return null
val mode =
when {
target.directive == "#load" || "file" in target.flags -> JaiPathMode.FILE
"dir" in target.flags -> JaiPathMode.DIRECTORY
rawPath.startsWith("./") || rawPath.startsWith("../") -> JaiPathMode.RELATIVE
else -> JaiPathMode.MODULE
}
if (path.startsWith('/')) {
return findAbsolute(path, mode)
}
val roots = candidateRoots(project, sourceFile, mode)
for (root in roots) {
findInRoot(root, path, mode)?.let { return it }
}
return null
}
private fun candidateRoots(
project: Project,
sourceFile: VirtualFile,
mode: JaiPathMode,
): List<VirtualFile> {
val roots = linkedMapOf<String, VirtualFile>()
fun add(root: VirtualFile?) {
if (root != null && root.isValid && root.isDirectory) roots.putIfAbsent(root.path, root)
}
val sourceRoot = sourceFile.parent
if (mode == JaiPathMode.MODULE) {
add(sourceRoot?.findChild("modules"))
val projectRoot = project.basePath?.let(fileSystem::findFileByPath)
add(projectRoot?.findChild("modules"))
add(projectRoot)
add(
fileSystem.findFileByPath(
"${System.getProperty("user.home")}/.local/jai/modules",
),
)
} else {
// #load, #import,file, #import,dir, and explicit ./ or ../ paths are
// direct paths. Do not silently find a same-named file in another root.
add(sourceRoot)
}
return roots.values.toList()
}
private fun findAbsolute(
path: String,
mode: JaiPathMode,
): VirtualFile? {
val direct = fileSystem.findFileByPath(path)
if (direct != null) {
when (mode) {
JaiPathMode.FILE -> {
if (!direct.isDirectory) return direct
}
JaiPathMode.DIRECTORY -> {
if (direct.isDirectory) return direct.findChild("module.jai")
}
JaiPathMode.RELATIVE -> {
return if (direct.isDirectory) direct.findChild("module.jai") else direct
}
JaiPathMode.MODULE -> {
if (!direct.isDirectory) return direct
direct.findChild("module.jai")?.let { return it }
}
}
}
if (mode == JaiPathMode.MODULE && !path.endsWith(".jai")) {
return fileSystem.findFileByPath("$path.jai")
?: fileSystem.findFileByPath("$path/module.jai")
}
return null
}
private fun findInRoot(
root: VirtualFile,
path: String,
mode: JaiPathMode,
): VirtualFile? {
val direct = root.findFileByRelativePath(path)
if (direct != null) {
when (mode) {
JaiPathMode.FILE -> {
if (!direct.isDirectory) return direct
}
JaiPathMode.DIRECTORY -> {
if (direct.isDirectory) return direct.findChild("module.jai")
}
JaiPathMode.RELATIVE -> {
return if (direct.isDirectory) direct.findChild("module.jai") else direct
}
JaiPathMode.MODULE -> {
if (!direct.isDirectory) return direct
direct.findChild("module.jai")?.let { return it }
}
}
}
if (mode == JaiPathMode.MODULE && !path.endsWith(".jai")) {
return root.findFileByRelativePath("$path.jai")
?: root.findFileByRelativePath("$path/module.jai")
}
return null
}
private fun normalize(path: String): String = path.replace('\\', '/').removePrefix("./")
}
private fun JaiDirectiveExpr.directiveName(): String? = node.findChildByType(JaiTokenTypes.DIRECTIVE)?.text
private fun JaiDirectiveExpr.flagsBefore(literal: PsiElement): Set<String> {

View File

@@ -0,0 +1,17 @@
package dev.hgh.jai.settings
import com.intellij.openapi.project.Project
import com.intellij.openapi.roots.AdditionalLibraryRootsProvider
import com.intellij.openapi.roots.SyntheticLibrary
import com.intellij.openapi.vfs.VirtualFile
/** Makes configured external Jai directories part of IntelliJ's indexed library scope. */
class JaiAdditionalLibraryRootsProvider : AdditionalLibraryRootsProvider() {
override fun getAdditionalProjectLibraries(project: Project): Collection<SyntheticLibrary> {
val roots = JaiProjectSettings.getInstance(project).configuredRoots()
if (roots.isEmpty()) return emptyList()
return listOf(SyntheticLibrary.newImmutableLibrary(roots))
}
override fun getRootsToWatch(project: Project): Collection<VirtualFile> = JaiProjectSettings.getInstance(project).configuredRoots()
}

View File

@@ -0,0 +1,119 @@
package dev.hgh.jai.settings
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.PersistentStateComponent
import com.intellij.openapi.components.State
import com.intellij.openapi.components.Storage
import com.intellij.openapi.components.StoragePathMacros
import com.intellij.openapi.project.Project
import com.intellij.openapi.project.RootsChangeRescanningInfo
import com.intellij.openapi.roots.ex.ProjectRootManagerEx
import com.intellij.openapi.util.io.FileUtil
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.openapi.vfs.VirtualFile
import java.io.File
/** Project-level paths used to find Jai modules and external Jai source files. */
@State(
name = "JaiProjectSettings",
storages = [Storage(StoragePathMacros.WORKSPACE_FILE)],
)
class JaiProjectSettings(
private val project: Project,
) : PersistentStateComponent<JaiProjectSettings.State> {
/** The persisted form deliberately contains paths, not VirtualFiles. */
class State {
@JvmField
var rootPaths: MutableList<String> = mutableListOf()
override fun equals(other: Any?): Boolean = other is State && rootPaths == other.rootPaths
override fun hashCode(): Int = rootPaths.hashCode()
}
private var state = State()
/** Configured roots in precedence order. Invalid paths remain visible in Settings. */
fun rootPaths(): List<String> = state.rootPaths.toList()
/** Existing configured directories, in the same order as their persisted paths. */
fun configuredRoots(): List<VirtualFile> =
rootPaths()
.mapNotNull { LocalFileSystem.getInstance().findFileByPath(it) }
.filter { it.isValid && it.isDirectory }
fun setRootPaths(paths: Collection<String>) {
val normalized = paths.mapNotNull(::normalizePath).distinct()
if (normalized == state.rootPaths) return
state = State().also { it.rootPaths.addAll(normalized) }
}
override fun getState(): State = State().also { it.rootPaths.addAll(state.rootPaths) }
override fun loadState(loadedState: State) {
val normalized = loadedState.rootPaths.mapNotNull(::normalizePath).distinct()
if (normalized == state.rootPaths) return
state = State().also { it.rootPaths.addAll(normalized) }
}
private fun normalizePath(rawPath: String): String? {
val trimmed = rawPath.trim()
if (trimmed.isEmpty()) return null
val expanded =
when {
trimmed == "~" -> {
System.getProperty("user.home")
}
trimmed.startsWith("~/") -> {
System.getProperty("user.home") + trimmed.removePrefix("~")
}
else -> {
trimmed
}
}
val file = File(expanded)
val absolute =
if (file.isAbsolute) {
file
} else {
project.basePath?.let { File(it, expanded) } ?: file.absoluteFile
}
return FileUtil.toSystemIndependentName(
absolute
.toPath()
.normalize()
.toAbsolutePath()
.toString(),
)
}
/** Rebuilds the project/index roots after Settings has been applied. */
fun refreshProjectRoots() {
if (!project.isInitialized || project.isDisposed) return
val refresh =
Runnable {
if (!project.isDisposed) {
ProjectRootManagerEx
.getInstanceEx(project)
.makeRootsChange(Runnable {}, RootsChangeRescanningInfo.TOTAL_RESCAN)
}
}
val application = ApplicationManager.getApplication()
if (application.isWriteAccessAllowed) {
refresh.run()
} else {
application.runWriteAction(refresh)
}
}
companion object {
@JvmStatic
fun getInstance(project: Project): JaiProjectSettings = project.getService(JaiProjectSettings::class.java)
}
}

View File

@@ -0,0 +1,138 @@
package dev.hgh.jai.settings
import com.intellij.openapi.fileChooser.FileChooser
import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
import com.intellij.openapi.options.Configurable
import com.intellij.openapi.options.SearchableConfigurable
import com.intellij.openapi.project.Project
import com.intellij.ui.JBColor
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBList
import com.intellij.ui.components.JBScrollPane
import java.awt.BorderLayout
import java.awt.FlowLayout
import javax.swing.DefaultListModel
import javax.swing.JButton
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.ListSelectionModel
/** Settings UI for project-specific Jai module/import roots. */
class JaiProjectSettingsConfigurable(
private val project: Project,
) : SearchableConfigurable,
Configurable.NoScroll {
private var component: JPanel? = null
private var rootsModel: DefaultListModel<String>? = null
private var rootsList: JBList<String>? = null
override fun getId(): String = "dev.hgh.intellijai.jai.settings"
override fun getDisplayName(): String = "Jai"
override fun createComponent(): JComponent {
val model = DefaultListModel<String>()
val list = JBList(model)
list.emptyText.text = "No configured roots"
list.selectionMode = ListSelectionModel.SINGLE_SELECTION
val addButton = JButton("Add…")
addButton.addActionListener {
val selected =
FileChooser.chooseFile(
FileChooserDescriptorFactory
.createSingleFolderDescriptor()
.withTitle("Select Jai module/import root"),
component,
project,
null,
)
if (selected != null && !contains(model, selected.path)) {
model.addElement(selected.path)
list.selectedIndex = model.size() - 1
}
}
val removeButton = JButton("Remove")
removeButton.addActionListener {
val selectedIndex = list.selectedIndex
if (selectedIndex >= 0) model.remove(selectedIndex)
}
val upButton = JButton("Move Up")
upButton.addActionListener { moveSelected(list, model, -1) }
val downButton = JButton("Move Down")
downButton.addActionListener { moveSelected(list, model, 1) }
val buttons = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0))
buttons.add(addButton)
buttons.add(removeButton)
buttons.add(upButton)
buttons.add(downButton)
val rootPanel = JPanel(BorderLayout(0, 8))
rootPanel.add(JBLabel("Jai module/import roots:"), BorderLayout.NORTH)
rootPanel.add(JBScrollPane(list), BorderLayout.CENTER)
val footer = JPanel(BorderLayout(0, 4))
footer.add(buttons, BorderLayout.NORTH)
footer.add(
JBLabel("Roots are searched in the order shown and are indexed as Jai library sources.")
.also { it.foreground = JBColor.GRAY },
BorderLayout.SOUTH,
)
rootPanel.add(footer, BorderLayout.SOUTH)
component = rootPanel
rootsModel = model
rootsList = list
reset()
return rootPanel
}
override fun getPreferredFocusedComponent(): JComponent? = rootsList
override fun isModified(): Boolean = rootsModel?.let { values(it) != JaiProjectSettings.getInstance(project).rootPaths() } ?: false
override fun apply() {
val model = rootsModel ?: return
val settings = JaiProjectSettings.getInstance(project)
settings.setRootPaths(values(model))
settings.refreshProjectRoots()
}
override fun reset() {
val model = rootsModel ?: return
model.removeAllElements()
JaiProjectSettings.getInstance(project).rootPaths().forEach(model::addElement)
}
override fun disposeUIResources() {
component = null
rootsModel = null
rootsList = null
}
private fun values(model: DefaultListModel<String>): List<String> = (0 until model.size()).map(model::getElementAt)
private fun contains(
model: DefaultListModel<String>,
path: String,
): Boolean = (0 until model.size()).any { model.getElementAt(it) == path }
private fun moveSelected(
list: JBList<String>,
model: DefaultListModel<String>,
delta: Int,
) {
val current = list.selectedIndex
val target = current + delta
if (current < 0 || target !in 0 until model.size()) return
val value = model.getElementAt(current)
model.remove(current)
model.add(target, value)
list.selectedIndex = target
}
}

View File

@@ -23,6 +23,16 @@
<lang.parserDefinition language="Jai" implementationClass="dev.hgh.jai.psi.JaiParserDefinition"/>
<projectService serviceImplementation="dev.hgh.jai.settings.JaiProjectSettings"/>
<projectConfigurable
parentId="language"
instance="dev.hgh.jai.settings.JaiProjectSettingsConfigurable"
id="dev.hgh.intellijai.jai.settings"
displayName="Jai"
nonDefaultProject="true"/>
<additionalLibraryRootsProvider
implementation="dev.hgh.jai.settings.JaiAdditionalLibraryRootsProvider"/>
<colorSettingsPage implementation="dev.hgh.jai.highlighting.JaiColorSettingsPage"/>
<lang.syntaxHighlighterFactory

View File

@@ -0,0 +1,173 @@
package dev.hgh.jai.settings
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.psi.PsiFile
import com.intellij.psi.PsiManager
import com.intellij.psi.PsiReferenceService
import com.intellij.psi.search.searches.ReferencesSearch
import com.intellij.psi.util.PsiTreeUtil
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import dev.hgh.jai.psi.JaiDeclName
import dev.hgh.jai.psi.JaiLiteralExpr
import dev.hgh.jai.psi.JaiRefExpr
import java.nio.file.Files
import java.nio.file.Path
class JaiConfiguredRootTest : BasePlatformTestCase() {
private var temporaryRoot: Path? = null
fun testConfiguredRootResolvesImportedModuleAndSymbol() {
val module = configureRoot("Custom/module.jai" to "Helper :: () {}")
val source =
myFixture.addFileToProject(
"consumer.jai",
"""
#import "Custom";
main :: () { Helper(); }
""".trimIndent(),
)
val moduleLiteral = literalWithText(source, "\"Custom\"")
assertEquals(
module.path,
onlyReference(moduleLiteral)
.resolve()
?.containingFile
?.virtualFile
?.path,
)
val symbolReference =
PsiTreeUtil
.findChildrenOfType(source, JaiRefExpr::class.java)
.single { it.text == "Helper" }
assertEquals(
module.path,
onlyReference(symbolReference)
.resolve()
?.containingFile
?.virtualFile
?.path,
)
}
fun testConfiguredRootResolvesLoadFileFromConfiguredRoot() {
val externalFile = configureRoot("external.jai" to "value :: 1;")
val source = myFixture.addFileToProject("consumer.jai", "#load \"external.jai\";")
val literal = literalWithText(source, "\"external.jai\"")
assertEquals(
externalFile.path,
onlyReference(literal)
.resolve()
?.containingFile
?.virtualFile
?.path,
)
}
fun testConfiguredRootSuppliesImportAndLoadCompletion() {
configureRoot(
"Custom/module.jai" to "value :: 1;",
"external.jai" to "value :: 1;",
)
val configuredRoots = JaiProjectSettings.getInstance(project).configuredRoots()
assertEquals("expected one configured VFS root", 1, configuredRoots.size)
assertNotNull("expected the configured module directory", configuredRoots.single().findChild("Custom"))
myFixture.configureByText("import-consumer.jai", "#import \"Cus<caret>\";")
myFixture.completeBasic()
myFixture.checkResult("#import \"Custom\";")
myFixture.configureByText("load-consumer.jai", "#load \"ext<caret>\";")
myFixture.completeBasic()
myFixture.checkResult("#load \"external.jai\";")
}
fun testReferencesSearchIncludesProjectUsagesForConfiguredExternalDeclaration() {
val module = configureRoot("Custom/module.jai" to "Helper :: () {}")
val source =
myFixture.addFileToProject(
"consumer.jai",
"""
#import "Custom";
main :: () { Helper(); }
""".trimIndent(),
)
JaiProjectSettings.getInstance(project).refreshProjectRoots()
val externalFile =
PsiManager
.getInstance(project)
.findFile(module)
assertNotNull("expected PSI for configured external module", externalFile)
val declaration =
PsiTreeUtil
.findChildrenOfType(externalFile!!, JaiDeclName::class.java)
.single { it.text == "Helper" }
assertTrue(
"configured Jai files should be indexed as library sources",
com.intellij.openapi.roots.ProjectFileIndex
.getInstance(project)
.isInLibrarySource(module),
)
val usages = ReferencesSearch.search(declaration).findAll()
assertEquals(
"expected the project usage; scope=${declaration.useScope}, usages=$usages",
1,
usages.size,
)
assertEquals("Helper", usages.single().element.text)
assertEquals(
source.virtualFile.path,
usages
.single()
.element.containingFile.virtualFile.path,
)
}
private fun configureRoot(vararg files: Pair<String, String>): com.intellij.openapi.vfs.VirtualFile {
val root = Files.createTempDirectory("jai-configured-root-").toAbsolutePath().normalize()
temporaryRoot = root
files.forEach { (relativePath, contents) ->
val file = root.resolve(relativePath)
Files.createDirectories(file.parent)
Files.writeString(file, contents)
}
val virtualRoot = LocalFileSystem.getInstance().refreshAndFindFileByNioFile(root)
assertNotNull("expected the configured root to be visible to VFS", virtualRoot)
JaiProjectSettings.getInstance(project).setRootPaths(listOf(root.toString()))
return virtualRoot!!.findFileByRelativePath(files.first().first)!!
}
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", literal)
return literal!!
}
private fun onlyReference(element: com.intellij.psi.PsiElement) =
PsiReferenceService
.getService()
.getContributedReferences(element)
.also { assertEquals("expected exactly one reference", 1, it.size) }
.single()
override fun tearDown() {
try {
super.tearDown()
} finally {
temporaryRoot?.toFile()?.deleteRecursively()
}
}
}

View File

@@ -0,0 +1,74 @@
package dev.hgh.jai.settings
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import java.nio.file.Files
import java.nio.file.Path
class JaiProjectSettingsTest : BasePlatformTestCase() {
private var temporaryRoot: Path? = null
fun testRootPathsNormalizeDeduplicateAndRoundTripThroughState() {
val root = Files.createTempDirectory("jai-settings-").toAbsolutePath().normalize()
temporaryRoot = root
val settings = JaiProjectSettings.getInstance(project)
settings.setRootPaths(
listOf(
" ${root.resolve(".")} ",
root.toString(),
"",
),
)
val expected = root.toString().replace('\\', '/')
assertEquals(listOf(expected), settings.rootPaths())
val savedState = settings.state
settings.setRootPaths(emptyList())
assertTrue(settings.rootPaths().isEmpty())
settings.loadState(savedState)
assertEquals(listOf(expected), settings.rootPaths())
assertEquals(listOf(expected), settings.state.rootPaths)
}
fun testConfiguredRootsArePublishedAsIndexedSyntheticLibrarySources() {
val root = Files.createTempDirectory("jai-library-root-").toAbsolutePath().normalize()
temporaryRoot = root
val virtualRoot =
LocalFileSystem
.getInstance()
.refreshAndFindFileByNioFile(root)
assertNotNull("expected the configured root to be visible to VFS", virtualRoot)
JaiProjectSettings.getInstance(project).setRootPaths(listOf(root.toString()))
val provider = JaiAdditionalLibraryRootsProvider()
val libraries = provider.getAdditionalProjectLibraries(project)
assertEquals(1, libraries.size)
assertTrue(libraries.single().sourceRoots.contains(virtualRoot))
assertTrue(provider.getRootsToWatch(project).contains(virtualRoot))
}
fun testSettingsConfigurableCreatesAndResetsAProjectPanel() {
val settings = JaiProjectSettings.getInstance(project)
val root = Files.createTempDirectory("jai-configurable-root-").toAbsolutePath().normalize()
temporaryRoot = root
settings.setRootPaths(listOf(root.toString()))
val configurable = JaiProjectSettingsConfigurable(project)
assertEquals("Jai", configurable.displayName)
assertNotNull(configurable.createComponent())
assertFalse("reset should load the persisted state", configurable.isModified)
configurable.disposeUIResources()
}
override fun tearDown() {
try {
super.tearDown()
} finally {
temporaryRoot?.toFile()?.deleteRecursively()
}
}
}