From 2bc1b9f4be1da6a69330d1c09429b204b728809f Mon Sep 17 00:00:00 2001 From: hgranthorner <37941012+hgranthorner@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:24:32 -0400 Subject: [PATCH] Phase 5a: add configurable Jai module roots --- AGENTS.md | 33 ++-- CHANGELOG.md | 7 +- README.md | 16 +- docs/BUILD_PLAN.md | 11 +- .../completion/JaiCompletionContributor.kt | 44 ++--- .../dev/hgh/jai/module/JaiModuleResolver.kt | 163 +++++++++++++++++ .../jai/reference/JaiReferenceContributor.kt | 149 +-------------- .../JaiAdditionalLibraryRootsProvider.kt | 17 ++ .../hgh/jai/settings/JaiProjectSettings.kt | 119 ++++++++++++ .../JaiProjectSettingsConfigurable.kt | 138 ++++++++++++++ src/main/resources/META-INF/plugin.xml | 10 + .../hgh/jai/settings/JaiConfiguredRootTest.kt | 173 ++++++++++++++++++ .../jai/settings/JaiProjectSettingsTest.kt | 74 ++++++++ 13 files changed, 740 insertions(+), 214 deletions(-) create mode 100644 src/main/kotlin/dev/hgh/jai/module/JaiModuleResolver.kt create mode 100644 src/main/kotlin/dev/hgh/jai/settings/JaiAdditionalLibraryRootsProvider.kt create mode 100644 src/main/kotlin/dev/hgh/jai/settings/JaiProjectSettings.kt create mode 100644 src/main/kotlin/dev/hgh/jai/settings/JaiProjectSettingsConfigurable.kt create mode 100644 src/test/kotlin/dev/hgh/jai/settings/JaiConfiguredRootTest.kt create mode 100644 src/test/kotlin/dev/hgh/jai/settings/JaiProjectSettingsTest.kt diff --git a/AGENTS.md b/AGENTS.md index 52f4e9f..86b2fb4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 (6–8). +- 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 0–4 vs 0–7), compiler integration, target IDEs, JDK policy. See +Scope (Phases 0–5a vs 0–7), compiler integration, target IDEs, JDK policy. See `docs/BUILD_PLAN.md` §6. Scope is the one that most affects the work. --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 89b03b6..eb3f352 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index a8b49fd..3f27781 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/BUILD_PLAN.md b/docs/BUILD_PLAN.md index fbeae4f..b4ffad7 100644 --- a/docs/BUILD_PLAN.md +++ b/docs/BUILD_PLAN.md @@ -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 0–5), or go all the way to formatter and + refactoring support (Phases 0–5a), or go all the way to formatter and inspections (0–7)? 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 0–5 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 6–8. +Phases 0–5a are implemented and verified headlessly. The next decision is +whether to continue with the optional formatter, inspections, and compiler +integration work in Phases 6–8. diff --git a/src/main/kotlin/dev/hgh/jai/completion/JaiCompletionContributor.kt b/src/main/kotlin/dev/hgh/jai/completion/JaiCompletionContributor.kt index 90bb316..4456cbe 100644 --- a/src/main/kotlin/dev/hgh/jai/completion/JaiCompletionContributor.kt +++ b/src/main/kotlin/dev/hgh/jai/completion/JaiCompletionContributor.kt @@ -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() 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 = - 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 = - collectCandidates( - listOfNotNull(sourceFile?.parent), - ) { child -> + fun loadPaths( + project: com.intellij.openapi.project.Project, + sourceFile: VirtualFile?, + ): List = + 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 { - val roots = linkedMapOf() - - 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() - } } diff --git a/src/main/kotlin/dev/hgh/jai/module/JaiModuleResolver.kt b/src/main/kotlin/dev/hgh/jai/module/JaiModuleResolver.kt new file mode 100644 index 0000000..8bb8fd7 --- /dev/null +++ b/src/main/kotlin/dev/hgh/jai/module/JaiModuleResolver.kt @@ -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, +) + +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 { + val roots = linkedMapOf() + + 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("./") +} diff --git a/src/main/kotlin/dev/hgh/jai/reference/JaiReferenceContributor.kt b/src/main/kotlin/dev/hgh/jai/reference/JaiReferenceContributor.kt index 19b8cba..6f32bbd 100644 --- a/src/main/kotlin/dev/hgh/jai/reference/JaiReferenceContributor.kt +++ b/src/main/kotlin/dev/hgh/jai/reference/JaiReferenceContributor.kt @@ -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, -) - -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 = 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 { - val roots = linkedMapOf() - - 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 { diff --git a/src/main/kotlin/dev/hgh/jai/settings/JaiAdditionalLibraryRootsProvider.kt b/src/main/kotlin/dev/hgh/jai/settings/JaiAdditionalLibraryRootsProvider.kt new file mode 100644 index 0000000..b56d28d --- /dev/null +++ b/src/main/kotlin/dev/hgh/jai/settings/JaiAdditionalLibraryRootsProvider.kt @@ -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 { + val roots = JaiProjectSettings.getInstance(project).configuredRoots() + if (roots.isEmpty()) return emptyList() + return listOf(SyntheticLibrary.newImmutableLibrary(roots)) + } + + override fun getRootsToWatch(project: Project): Collection = JaiProjectSettings.getInstance(project).configuredRoots() +} diff --git a/src/main/kotlin/dev/hgh/jai/settings/JaiProjectSettings.kt b/src/main/kotlin/dev/hgh/jai/settings/JaiProjectSettings.kt new file mode 100644 index 0000000..7f74a94 --- /dev/null +++ b/src/main/kotlin/dev/hgh/jai/settings/JaiProjectSettings.kt @@ -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 { + /** The persisted form deliberately contains paths, not VirtualFiles. */ + class State { + @JvmField + var rootPaths: MutableList = 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 = state.rootPaths.toList() + + /** Existing configured directories, in the same order as their persisted paths. */ + fun configuredRoots(): List = + rootPaths() + .mapNotNull { LocalFileSystem.getInstance().findFileByPath(it) } + .filter { it.isValid && it.isDirectory } + + fun setRootPaths(paths: Collection) { + 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) + } +} diff --git a/src/main/kotlin/dev/hgh/jai/settings/JaiProjectSettingsConfigurable.kt b/src/main/kotlin/dev/hgh/jai/settings/JaiProjectSettingsConfigurable.kt new file mode 100644 index 0000000..7c59356 --- /dev/null +++ b/src/main/kotlin/dev/hgh/jai/settings/JaiProjectSettingsConfigurable.kt @@ -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? = null + private var rootsList: JBList? = null + + override fun getId(): String = "dev.hgh.intellijai.jai.settings" + + override fun getDisplayName(): String = "Jai" + + override fun createComponent(): JComponent { + val model = DefaultListModel() + 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): List = (0 until model.size()).map(model::getElementAt) + + private fun contains( + model: DefaultListModel, + path: String, + ): Boolean = (0 until model.size()).any { model.getElementAt(it) == path } + + private fun moveSelected( + list: JBList, + model: DefaultListModel, + 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 + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 141a939..d1b1ded 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -23,6 +23,16 @@ + + + + \";") + myFixture.completeBasic() + myFixture.checkResult("#import \"Custom\";") + + myFixture.configureByText("load-consumer.jai", "#load \"ext\";") + 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): 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() + } + } +} diff --git a/src/test/kotlin/dev/hgh/jai/settings/JaiProjectSettingsTest.kt b/src/test/kotlin/dev/hgh/jai/settings/JaiProjectSettingsTest.kt new file mode 100644 index 0000000..0e5283c --- /dev/null +++ b/src/test/kotlin/dev/hgh/jai/settings/JaiProjectSettingsTest.kt @@ -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() + } + } +}