Phase 5a: add configurable Jai module roots
This commit is contained in:
33
AGENTS.md
33
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
|
||||
16
README.md
16
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
163
src/main/kotlin/dev/hgh/jai/module/JaiModuleResolver.kt
Normal file
163
src/main/kotlin/dev/hgh/jai/module/JaiModuleResolver.kt
Normal 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("./")
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
119
src/main/kotlin/dev/hgh/jai/settings/JaiProjectSettings.kt
Normal file
119
src/main/kotlin/dev/hgh/jai/settings/JaiProjectSettings.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
173
src/test/kotlin/dev/hgh/jai/settings/JaiConfiguredRootTest.kt
Normal file
173
src/test/kotlin/dev/hgh/jai/settings/JaiConfiguredRootTest.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user