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

@@ -75,7 +75,9 @@ dev.hgh.jai.parser.DebugParseTest tests=2 <- scratch harness, ine
dev.hgh.jai.refactoring.JaiRenameTest tests=3
dev.hgh.jai.reference.JaiReferenceTest tests=9
dev.hgh.jai.structure.JaiStructureViewTest tests=3
-> total 71, failures+errors 0
dev.hgh.jai.settings.JaiProjectSettingsTest tests=3
dev.hgh.jai.settings.JaiConfiguredRootTest tests=4
-> total 78, failures+errors 0
```
The corpus gates report what they actually did; check both lines are still there:
@@ -118,6 +120,13 @@ Tier 3: parsed 714 files, 714 clean (100.0%), 0 with errors, 0 PsiErrorElements
`#import`/`#load` path references with standard go-to-definition resolution.
Headless tests cover source-ordered declarations, nested structure members,
folding ranges, local `#load`, and Jai module directories.
- **Phase 5** — completion, same-file and imported symbol navigation, rename,
and find-usages for declaration names.
- **Phase 5a** — persistent project settings for Jai module/import roots,
shared root-aware resolution and completion, and indexed external Jai library
sources. Headless tests cover state round-tripping, the Settings panel,
custom-root `#import`/`#load` navigation and completion, and cross-root
find-usages.
### Lexer design facts worth knowing before touching it
@@ -179,30 +188,20 @@ Tier 3: parsed 714 files, 714 clean (100.0%), 0 with errors, 0 PsiErrorElements
### In progress — pick up here
1. **Phase 5 completion and navigation** — basic completion for keywords,
compiler directives, `#import`/`#load` module paths is implemented in
`dev.hgh.jai.completion.JaiCompletionContributor`. Identifier references now
resolve procedures/types in the same file and transitively loaded/imported
files. Declaration names now support rename and find-usages through the
standard PSI contracts; module-qualified members, parameters, and fields
remain future symbol-resolution work.
2. **Phases 6+** — formatter, inspections, compiler integration, and other
1. **Phases 6+** — formatter, inspections, compiler integration, and other
optional work remain. `#asm` bodies are intentionally consumed opaquely, so
nothing inside them has PSI yet.
### Next planned increment
- Add persistent project settings for configurable Jai module/import roots.
- Use those roots consistently for `#import`/`#load` resolution, completion,
symbol navigation, and find-usages.
- Include configured external `.jai` directories in indexing/search scope so
standard-library and custom-module usages are discoverable across files.
- Add headless tests for settings persistence, custom-root resolution,
completion, go-to-definition, and cross-root find-usages.
- Decide whether to continue with the optional formatter, inspections, and
compiler integration phases (68).
- If formatter work begins, add the corpus-wide idempotence gate described in
`docs/BUILD_PLAN.md` before changing formatting behavior.
### Open questions for the user (unanswered)
Scope (Phases 04 vs 07), compiler integration, target IDEs, JDK policy. See
Scope (Phases 05a vs 07), compiler integration, target IDEs, JDK policy. See
`docs/BUILD_PLAN.md` §6. Scope is the one that most affects the work.
---

View File

@@ -21,8 +21,10 @@
- Go-to-definition references for `#import` and `#load`, including project-relative
files and the local Jai module layout (`Name.jai` or `Name/module.jai`).
- Basic completion for Jai keywords, compiler directives, and module/file paths.
- Project-level Jai module/import root settings with ordered custom roots.
- Go-to-definition for procedure and type symbols in the same file and through
loaded or imported Jai files.
loaded, imported, or configured external Jai files.
- Indexed external Jai roots so find-usages can cross project/library boundaries.
- Rename and find-usages support for declaration names and resolved references.
### Known gaps
@@ -31,8 +33,5 @@
not have inner PSI.
- `#asm` bodies remain intentionally opaque; assembly instructions do not have
inner PSI or structure entries.
- Import resolution currently uses fixed project/module roots and the local Jai
installation; configurable module roots and custom compiler `-import_dir`
arguments are planned.
- Module-qualified members, procedure parameters, and struct fields do not yet
have symbol resolution.

View File

@@ -17,16 +17,16 @@ never from online material (see `AGENTS.md`).
- Comment / uncomment (`//` and nesting `/* */`)
- Brace matching, including the Jai-only `.{` struct-literal and `.[`
array-literal openers
- Basic completion for keywords, directives, and local Jai module/file paths
- Basic completion for keywords, directives, and Jai module/file paths
- Project settings for ordered Jai module/import roots
- Go-to-definition for procedures and types in the same file and through
`#import`/`#load`
`#import`/`#load`, including configured external roots
- Indexed external Jai roots so find-usages can cross project/library boundaries
- Rename and find-usages for declaration names and their resolved references
Remaining code-insight gaps include module-qualified members, parameters, and
struct fields. The next planned increment is configurable project module/import
roots, including indexed search scope for external standard-library and custom
module directories. Formatting, inspections, and compiler integration are
planned later. See `docs/BUILD_PLAN.md` for the phase plan.
struct fields. Formatting, inspections, and compiler integration are planned
later. See `docs/BUILD_PLAN.md` for the phase plan.
## Install it locally
@@ -39,7 +39,9 @@ In the IDE: **Settings → Plugins → ⚙ → Install Plugin from Disk…**, pi
ZIP, restart when prompted. Requires build 253 (2025.3) or newer; verified
compatible with IU-253, IU-261 and IU-262 by the JetBrains Plugin Verifier.
To try it, open any file from `~/.local/jai/how_to/`.
To try it, open any file from `~/.local/jai/how_to/`. Configure additional
module/import directories under **Settings → Languages & Frameworks → Jai**;
configured roots are searched in order and indexed as Jai library sources.
Uninstall or update the same way — installing a new ZIP over the old one
replaces it.

View File

@@ -170,7 +170,7 @@ Each phase has a machine-checkable gate. Do not advance without a green gate.
| **3** | `.bnf` grammar, generated parser + PSI, `ParserDefinition` | Tier 2 golden trees; Tier 3 corpus parse ≥ target — **done, 100.0% (714/714)** |
| **4** | Structure view, folding, `#import`/`#load` reference resolution + go-to-definition | Tier 4 fixture tests — **done** |
| **5** | Completion (keywords, directives, module names), rename, find-usages | Tier 4 fixture tests — **done** (71-test suite) |
| **5a** | Configurable Jai module/import roots and indexed external search scope | Tier 4 settings, resolution, completion, navigation, and find-usages tests |
| **5a** | Configurable Jai module/import roots and indexed external search scope | Tier 4 settings, resolution, completion, navigation, and find-usages tests**done** (78-test suite) |
| **6** | Formatter, code style settings | Formatter round-trip: formatting the corpus is idempotent |
| **7** | Inspections (e.g. `#must` misuse), quick fixes, live templates | Tier 4 + `verifyPlugin` |
| **8** | Optional: run-configuration to invoke the `jai` compiler, parse its error output | Integration test against `~/.local/jai/bin` |
@@ -198,7 +198,7 @@ corpus-scale invariant that needs no human judgment.
## 6. Open questions for you
1. **Scope/ambition** — stop at the current syntax, navigation, and basic
refactoring support (Phases 05), or go all the way to formatter and
refactoring support (Phases 05a), or go all the way to formatter and
inspections (07)?
2. **Compiler integration** (Phase 8) — worth it? It's the only phase needing
the actual `jai` binary, and it's the least testable.
@@ -208,7 +208,6 @@ corpus-scale invariant that needs no human judgment.
setup, works today), or install a system JDK via Homebrew? I'd default to the
wrapper.
Phases 05 are implemented and verified headlessly. The next planned increment
is Phase 5a: configurable module/import roots and indexed external search scope.
After that, decide whether to continue with the optional formatter, inspections,
and compiler integration work in Phases 68.
Phases 05a are implemented and verified headlessly. The next decision is
whether to continue with the optional formatter, inspections, and compiler
integration work in Phases 68.

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()
}
}
}