improved completion

This commit is contained in:
hgranthorner
2026-08-05 16:32:40 -04:00
parent ede8522716
commit 98e25c5468
10 changed files with 832 additions and 139 deletions

View File

@@ -6,6 +6,7 @@ import com.intellij.codeInsight.completion.CompletionProvider
import com.intellij.codeInsight.completion.CompletionResultSet
import com.intellij.codeInsight.completion.CompletionType
import com.intellij.codeInsight.lookup.LookupElementBuilder
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.patterns.PlatformPatterns
@@ -17,8 +18,11 @@ import dev.hgh.jai.lexer.JaiTokenTypes
import dev.hgh.jai.module.JaiModuleResolver
import dev.hgh.jai.module.JaiPathMode
import dev.hgh.jai.psi.JaiDirectiveExpr
import dev.hgh.jai.reference.JaiSymbolResolver
/** Directives and their adjacent comma flags before an import/load string. */
private const val COMPLETION_DEBUG_PROPERTY = "jai.completion.debug"
private val LOG = Logger.getInstance("dev.hgh.jai.completion")
private val DIRECTIVE_WITH_FLAGS =
Regex("""#(import|load)((?:\s*,\s*[A-Za-z_][A-Za-z0-9_]*)*)\s*$""")
@@ -43,6 +47,12 @@ private class JaiCompletionProvider : CompletionProvider<CompletionParameters>()
) {
val completionContext = JaiCompletionContext(parameters)
val moduleDirective = completionContext.moduleDirective
debug(
"request file=${parameters.originalFile.virtualFile?.path ?: parameters.originalFile.name} " +
"offset=${parameters.offset} token=${completionContext.debugToken} " +
"prefix='${completionContext.prefix}' module=$moduleDirective " +
"member=${completionContext.memberContext}",
)
when {
moduleDirective != null -> {
addModuleNames(parameters, moduleDirective, completionContext.prefix, resultSet)
@@ -52,12 +62,18 @@ private class JaiCompletionProvider : CompletionProvider<CompletionParameters>()
return
}
completionContext.memberContext != null -> {
val member = completionContext.memberContext!!
addMemberSymbols(parameters, member, resultSet)
}
completionContext.isDirectivePrefix -> {
addDirectives(completionContext.prefix, resultSet)
}
else -> {
addKeywords(completionContext.prefix, resultSet)
addSymbols(parameters, completionContext.prefix, resultSet)
}
}
}
@@ -100,6 +116,53 @@ private class JaiCompletionProvider : CompletionProvider<CompletionParameters>()
}
}
private fun addSymbols(
parameters: CompletionParameters,
prefix: String,
resultSet: CompletionResultSet,
) {
val candidates = JaiSymbolResolver.completionCandidates(parameters.originalFile, parameters.position)
debug("unqualified candidates=${candidates.size} names=${candidates.take(40).joinToString { it.name }}")
candidates
.filter { it.name.startsWith(prefix) }
.forEach { candidate ->
resultSet.addElement(
LookupElementBuilder
.create(candidate.name)
.withPsiElement(candidate.element)
.withTypeText(candidate.kind),
)
}
}
private fun addMemberSymbols(
parameters: CompletionParameters,
member: JaiCompletionContext.MemberContext,
resultSet: CompletionResultSet,
) {
val candidates =
JaiSymbolResolver.memberCompletionCandidates(
parameters.originalFile,
parameters.position,
member.qualifier,
)
debug("member qualifier=${member.qualifier} candidates=${candidates.size} names=${candidates.take(40).joinToString { it.name }}")
candidates
.filter { it.name.startsWith(member.prefix) }
.forEach { candidate ->
resultSet.addElement(
LookupElementBuilder
.create(candidate.name)
.withPsiElement(candidate.element)
.withTypeText(candidate.kind),
)
}
}
private fun debug(message: String) {
if (java.lang.Boolean.getBoolean(COMPLETION_DEBUG_PROPERTY)) LOG.info(message)
}
private fun addModuleNames(
parameters: CompletionParameters,
directive: String,
@@ -149,6 +212,23 @@ private class JaiCompletionContext(
val isDirectivePrefix: Boolean
get() = token?.type === JaiTokenTypes.DIRECTIVE || token?.type === JaiTokenTypes.HASH
val debugToken: String
get() = token?.let { "${it.type}@${it.start}..${it.end}" } ?: "none"
data class MemberContext(
val qualifier: String,
val prefix: String,
)
val memberContext: MemberContext?
get() =
MEMBER_PREFIX.find(text.substring(0, offset))?.let { match ->
MemberContext(
qualifier = match.groupValues[1],
prefix = match.groupValues.getOrNull(2).orEmpty(),
)
}
val prefix: String
get() {
val current = token ?: return ""
@@ -210,6 +290,11 @@ private class JaiCompletionContext(
val end: Int,
)
private companion object {
val MEMBER_PREFIX =
Regex("""(?:^|[^A-Za-z0-9_`])(`?[A-Za-z_][A-Za-z0-9_]*`?)\s*\.\s*(`?[A-Za-z_][A-Za-z0-9_]*`?)?$""")
}
private fun tokenAtCaret(
text: String,
offset: Int,

View File

@@ -3,22 +3,16 @@ package dev.hgh.jai.reference
import com.intellij.openapi.util.TextRange
import com.intellij.patterns.PlatformPatterns
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiFile
import com.intellij.psi.PsiManager
import com.intellij.psi.PsiReference
import com.intellij.psi.PsiReferenceBase
import com.intellij.psi.PsiReferenceContributor
import com.intellij.psi.PsiReferenceProvider
import com.intellij.psi.PsiReferenceRegistrar
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
import dev.hgh.jai.psi.JaiDirectiveExpr
import dev.hgh.jai.psi.JaiLiteralExpr
import dev.hgh.jai.psi.JaiPsiElementFactory
@@ -115,109 +109,6 @@ private class JaiSymbolReference(
override fun getVariants(): Array<Any> = emptyArray()
}
private object JaiSymbolResolver {
private data class Candidate(
val name: JaiDeclName,
val scope: JaiBlock?,
)
fun resolve(reference: JaiRefExpr): JaiDeclName? {
val file = reference.containingFile ?: return null
resolveInFile(file, reference)?.let { return it }
return resolveImported(file, reference.text, linkedSetOf())
}
private fun resolveInFile(
file: PsiFile,
reference: JaiRefExpr,
): JaiDeclName? {
val candidates =
PsiTreeUtil
.findChildrenOfType(file, JaiDeclaration::class.java)
.flatMap { declaration ->
declaration.declNames.declNameList.map { name ->
Candidate(name, PsiTreeUtil.getParentOfType(name, JaiBlock::class.java))
}
}.filter { candidate ->
symbolName(candidate.name) == reference.text && isVisible(candidate, reference)
}.sortedWith(
compareByDescending<Candidate> { scopeDepth(it.scope) }
.thenByDescending {
it.name.textRange.startOffset <= reference.textRange.startOffset
}.thenBy {
kotlin.math.abs(it.name.textRange.startOffset - reference.textRange.startOffset)
},
)
return candidates.firstOrNull()?.name
}
private fun resolveImported(
file: PsiFile,
referenceName: String,
visited: MutableSet<String>,
): JaiDeclName? {
val fileKey = file.virtualFile?.path ?: file.name
if (!visited.add(fileKey)) return null
for (importedFile in importedFiles(file)) {
topLevelDeclaration(importedFile, referenceName)?.let { return it }
resolveImported(importedFile, referenceName, visited)?.let { return it }
}
return null
}
private fun topLevelDeclaration(
file: PsiFile,
referenceName: String,
): JaiDeclName? =
PsiTreeUtil
.findChildrenOfType(file, JaiDeclaration::class.java)
.asSequence()
.flatMap { declaration -> declaration.declNames.declNameList.asSequence() }
.firstOrNull {
PsiTreeUtil.getParentOfType(it, JaiBlock::class.java) == null &&
symbolName(it) == referenceName
}
private fun importedFiles(file: PsiFile): List<PsiFile> =
PsiTreeUtil
.findChildrenOfType(file, JaiLiteralExpr::class.java)
.mapNotNull { literal ->
val directive = literal.parent as? JaiDirectiveExpr ?: return@mapNotNull null
val name = directive.directiveName() ?: return@mapNotNull null
if (name != "#import" && name != "#load") return@mapNotNull null
PsiReferenceService
.getService()
.getContributedReferences(literal)
.firstOrNull()
?.resolve() as? PsiFile
}.distinctBy { it.virtualFile?.path ?: it.name }
private fun isVisible(
candidate: Candidate,
reference: JaiRefExpr,
): Boolean = candidate.scope == null || PsiTreeUtil.isAncestor(candidate.scope, reference, false)
private fun scopeDepth(scope: JaiBlock?): Int {
var current: PsiElement? = scope
var depth = 0
while (current != null) {
if (current is JaiBlock) depth++
current = current.parent
}
return depth
}
private fun symbolName(name: JaiDeclName): String {
val text = name.text
return if (text.length >= 2 && text.first() == '`' && text.last() == '`') {
text.substring(1, text.length - 1)
} else {
text
}
}
}
private class JaiModuleReference(
private val sourceElement: PsiElement,
rangeInElement: TextRange,

View File

@@ -0,0 +1,410 @@
package dev.hgh.jai.reference
import com.intellij.openapi.diagnostic.Logger
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiFile
import com.intellij.psi.PsiManager
import com.intellij.psi.PsiReferenceService
import com.intellij.psi.util.PsiTreeUtil
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
import dev.hgh.jai.psi.JaiDirectiveExpr
import dev.hgh.jai.psi.JaiForName
import dev.hgh.jai.psi.JaiForStatement
import dev.hgh.jai.psi.JaiInitializer
import dev.hgh.jai.psi.JaiLiteralExpr
import dev.hgh.jai.psi.JaiParamName
import dev.hgh.jai.psi.JaiProcLiteralExpr
import dev.hgh.jai.psi.JaiRefExpr
import dev.hgh.jai.settings.JaiProjectSettings
private const val COMPLETION_DEBUG_PROPERTY = "jai.completion.debug"
private val LOG = Logger.getInstance("dev.hgh.jai.reference.JaiSymbolResolver")
private val RECOVERABLE_IMPORT =
Regex("""(?m)(#(?:import|load))((?:\s*,\s*[A-Za-z_][A-Za-z0-9_]*)*)\s*"((?:\\.|[^"\\])*)"""")
private val RECOVERABLE_PROCEDURE =
Regex("""(?s)(?:^|[;}\n])\s*[A-Za-z_][A-Za-z0-9_]*\s*::\s*\([^{}]*\)\s*(?:->[^\{;}]*)?\{""")
private val RECOVERABLE_LOCAL_DECLARATION =
Regex("""(?m)(?:^|[;{}])\s*([A-Za-z_][A-Za-z0-9_]*)\s*(?::=|:(?!=))""")
/** A declaration-like PSI element that can be inserted by identifier completion. */
internal data class JaiSymbolCandidate(
val element: PsiElement,
val name: String,
val kind: String = "symbol",
)
/** Shared symbol lookup for references and symbol-aware completion. */
internal object JaiSymbolResolver {
private data class ScopedSymbol(
val element: PsiElement,
val name: String,
val scope: PsiElement?,
val fallbackScope: PsiElement?,
val visibleFrom: Int,
)
private data class ImportBinding(
val file: PsiFile,
val scope: PsiElement?,
val visibleFrom: Int,
val boundName: String?,
val importsIntoScope: Boolean,
val excludedNames: Set<String>,
)
fun resolve(reference: JaiRefExpr): PsiElement? {
val file = reference.containingFile ?: return null
return completionCandidates(file, reference)
.firstOrNull { it.name == reference.text }
?.element
}
/** Returns visible local declarations followed by visible unqualified imports. */
fun completionCandidates(
file: PsiFile,
position: PsiElement,
): List<JaiSymbolCandidate> {
val candidates = linkedMapOf<String, JaiSymbolCandidate>()
val localSymbols = localSymbols(file)
val recoveredLocals = recoverLocalSymbols(file, position)
localSymbols
.filter { isVisible(it, position) && !isInside(it.element, position) }
.sortedWith(
compareByDescending<ScopedSymbol> { scopeDepth(it.scope ?: it.fallbackScope) }
.thenBy { it.visibleFrom }
.thenBy { it.element.textOffset },
).forEach { symbol ->
candidates.putIfAbsent(
symbol.name,
JaiSymbolCandidate(symbol.element, symbol.name),
)
}
recoveredLocals.forEach { symbol ->
candidates.putIfAbsent(symbol.name, symbol)
}
importedCandidates(file, position).forEach { symbol ->
candidates.putIfAbsent(symbol.name, symbol)
}
val result = candidates.values.toList()
debug(
"file=${file.virtualFile?.path ?: file.name} roots=${JaiProjectSettings.getInstance(file.project).rootPaths()} " +
"locals=${localSymbols.joinToString {
"${it.name}@${it.element.textOffset}/scope=${it.scope?.textRange}/fallback=${it.fallbackScope?.textRange}"
}} recovered=${recoveredLocals.joinToString { it.name }} " +
"imports=${importBindings(file).joinToString { it.file.virtualFile?.path ?: it.file.name }} " +
"candidates=${result.take(40).joinToString { it.name }}",
)
return result
}
/** Returns members of a module bound to [qualifier] in the visible lexical scope. */
fun memberCompletionCandidates(
file: PsiFile,
position: PsiElement,
qualifier: String,
): List<JaiSymbolCandidate> {
val binding =
importBindings(file)
.filter { it.boundName == normalizeName(qualifier) && isVisible(it, position) }
.sortedWith(
compareByDescending<ImportBinding> { scopeDepth(it.scope) }
.thenByDescending { it.visibleFrom },
).firstOrNull()
?: return emptyList()
return topLevelDeclarations(binding.file)
.asSequence()
.filter { symbolName(it) !in binding.excludedNames }
.mapNotNull { name ->
symbolName(name)?.let { JaiSymbolCandidate(name, it, "member") }
}.distinctBy { it.name }
.toList()
}
private fun localSymbols(file: PsiFile): List<ScopedSymbol> {
val declarations =
PsiTreeUtil
.findChildrenOfType(file, JaiDeclaration::class.java)
.flatMap { declaration ->
declaration.declNames.declNameList.mapNotNull { name ->
symbolName(name)?.let {
ScopedSymbol(
element = name,
name = it,
scope = PsiTreeUtil.getParentOfType(name, JaiBlock::class.java),
fallbackScope = PsiTreeUtil.getParentOfType(name, JaiProcLiteralExpr::class.java),
visibleFrom = name.textOffset,
)
}
}
}
val parameters =
PsiTreeUtil
.findChildrenOfType(file, JaiParamName::class.java)
.mapNotNull { name ->
val symbol = symbolName(name) ?: return@mapNotNull null
val procedure = PsiTreeUtil.getParentOfType(name, JaiProcLiteralExpr::class.java)
ScopedSymbol(
element = name,
name = symbol,
// Keep the procedure itself as the parameter scope. The body block is a
// nested scope, so a local declaration correctly shadows a parameter.
scope = procedure,
fallbackScope = null,
visibleFrom = procedure?.textOffset ?: name.textOffset,
)
}
val forNames =
PsiTreeUtil
.findChildrenOfType(file, JaiForName::class.java)
.mapNotNull { name ->
val symbol = symbolName(name) ?: return@mapNotNull null
ScopedSymbol(
element = name,
name = symbol,
scope = PsiTreeUtil.getParentOfType(name, JaiForStatement::class.java),
fallbackScope = PsiTreeUtil.getParentOfType(name, JaiProcLiteralExpr::class.java),
visibleFrom = name.textOffset,
)
}
return declarations + parameters + forNames
}
private fun importedCandidates(
file: PsiFile,
position: PsiElement,
): List<JaiSymbolCandidate> =
importBindings(file)
.asSequence()
.filter { (it.boundName == null || it.importsIntoScope) && isVisible(it, position) }
.flatMap { binding ->
topLevelDeclarations(binding.file)
.asSequence()
.filter { symbolName(it) !in binding.excludedNames }
.mapNotNull { name ->
symbolName(name)?.let { JaiSymbolCandidate(name, it, "import") }
}
}.distinctBy { it.name }
.toList()
/** Recovers simple locals when an unfinished statement prevents the procedure PSI from closing. */
private fun recoverLocalSymbols(
file: PsiFile,
position: PsiElement,
): List<JaiSymbolCandidate> {
val caretOffset = position.textOffset.coerceIn(0, file.textLength)
val beforeCaret = file.text.substring(0, caretOffset)
val procedure = RECOVERABLE_PROCEDURE.findAll(beforeCaret).lastOrNull() ?: return emptyList()
val bodyStart = procedure.range.last + 1
if (bodyStart >= beforeCaret.length) return emptyList()
return RECOVERABLE_LOCAL_DECLARATION
.findAll(beforeCaret.substring(bodyStart))
.map { match -> JaiSymbolCandidate(position, match.groupValues[1], "local") }
.distinctBy { it.name }
.toList()
}
private fun importBindings(file: PsiFile): List<ImportBinding> {
val parsed =
PsiTreeUtil
.findChildrenOfType(file, JaiLiteralExpr::class.java)
.mapNotNull { literal ->
val directive = literal.parent as? JaiDirectiveExpr ?: return@mapNotNull null
val name = directive.directiveName() ?: return@mapNotNull null
if (name != "#import" && name != "#load") return@mapNotNull null
val declaration = boundDeclaration(literal)
val boundName =
declaration
?.declNames
?.declNameList
?.firstOrNull()
?.let(::symbolName)
val declarationPrefix = declaration?.let { prefixBeforeNames(it) }.orEmpty()
val importsIntoScope = declaration == null || declarationPrefix.trimStart().startsWith("using")
val excludedNames = excludedNames(declarationPrefix)
val target =
PsiReferenceService
.getService()
.getContributedReferences(literal)
.firstOrNull()
?.resolve() as? PsiFile
?: return@mapNotNull null
ImportBinding(
file = target,
scope = PsiTreeUtil.getParentOfType(literal, JaiBlock::class.java),
visibleFrom = literal.textOffset,
boundName = boundName,
importsIntoScope = importsIntoScope,
excludedNames = excludedNames,
)
}
// An unfinished statement immediately before the caret can make Grammar-Kit retain the
// file's top-level declarations but omit later directive PSI. Recover bare imports from
// the source text so completion still works while the user is typing.
val recovered = if (parsed.isEmpty()) recoverImportBindings(file) else emptyList()
return (parsed + recovered).distinctBy { binding ->
listOf(
binding.file.virtualFile?.path ?: binding.file.name,
binding.scope?.textRange?.startOffset,
binding.boundName,
binding.visibleFrom,
)
}
}
private fun recoverImportBindings(file: PsiFile): List<ImportBinding> {
val sourceFile = file.virtualFile ?: return emptyList()
return RECOVERABLE_IMPORT
.findAll(file.text)
.mapNotNull { match ->
val directive = match.groupValues[1]
val flags =
Regex("""[A-Za-z_][A-Za-z0-9_]*""")
.findAll(match.groupValues[2])
.map { it.value }
.toSet()
if ("string" in flags) return@mapNotNull null
val path = unescapeImportPath(match.groupValues[3])
val target =
JaiModuleResolver.resolve(
file.project,
sourceFile,
JaiModuleTarget(directive, path, flags),
) ?: return@mapNotNull null
val targetPsi = PsiManager.getInstance(file.project).findFile(target) ?: return@mapNotNull null
ImportBinding(
file = targetPsi,
scope = null,
visibleFrom = match.range.first,
boundName = null,
importsIntoScope = true,
excludedNames = emptySet(),
)
}.toList()
}
private fun unescapeImportPath(text: String): String =
buildString(text.length) {
var index = 0
while (index < text.length) {
if (text[index] == '\\' && index + 1 < text.length) {
append(text[index + 1])
index += 2
} else {
append(text[index++])
}
}
}
/** The declaration that binds an import, not an enclosing procedure declaration. */
private fun boundDeclaration(literal: JaiLiteralExpr): JaiDeclaration? {
val initializer = PsiTreeUtil.getParentOfType(literal, JaiInitializer::class.java) ?: return null
return PsiTreeUtil.getParentOfType(initializer, JaiDeclaration::class.java)
}
private fun prefixBeforeNames(declaration: JaiDeclaration): String {
val start = declaration.textRange.startOffset
val end = declaration.declNames.textRange.startOffset
return declaration.containingFile.text.substring(start, end)
}
private fun excludedNames(prefix: String): Set<String> {
val body = Regex("""except\s*\(([^)]*)\)""").find(prefix)?.groupValues?.get(1) ?: return emptySet()
return body
.split(',')
.map { normalizeName(it.trim()) }
.filter(String::isNotEmpty)
.toSet()
}
private fun topLevelDeclarations(file: PsiFile): List<JaiDeclName> =
PsiTreeUtil
.findChildrenOfType(file, JaiDeclaration::class.java)
.asSequence()
.flatMap { declaration -> declaration.declNames.declNameList.asSequence() }
.filter { PsiTreeUtil.getParentOfType(it, JaiBlock::class.java) == null }
.sortedBy { it.textOffset }
.toList()
private fun isVisible(
symbol: ScopedSymbol,
position: PsiElement,
): Boolean {
val inScope =
when {
symbol.scope == null && symbol.fallbackScope == null -> true
symbol.scope?.textRange?.contains(position.textOffset) == true -> true
symbol.fallbackScope?.textRange?.contains(position.textOffset) == true -> true
else -> false
}
if (!inScope) return false
// Block-local declarations and loop names are not visible before their declaration. Keep
// top-level declarations available for forward references, which Jai permits.
if (symbol.scope != null && symbol.visibleFrom > position.textOffset) return false
return true
}
private fun isVisible(
binding: ImportBinding,
position: PsiElement,
): Boolean =
// File-scope imports are declarations for the whole file even when the directive is
// written below its first use (a common Jai layout). Block-local imports remain ordered.
(binding.scope == null || binding.scope.textRange.contains(position.textOffset)) &&
(binding.scope == null || binding.visibleFrom <= position.textOffset)
private fun isInside(
element: PsiElement,
position: PsiElement,
): Boolean = element == position || element.textRange.contains(position.textOffset)
private fun scopeDepth(scope: PsiElement?): Int {
var current = scope
var depth = 0
while (current != null) {
if (current is JaiBlock || current is JaiForStatement || current is JaiProcLiteralExpr) depth++
current = current.parent
}
return depth
}
private fun debug(message: String) {
if (java.lang.Boolean.getBoolean(COMPLETION_DEBUG_PROPERTY)) LOG.info(message)
}
private fun symbolName(element: PsiElement): String? {
val text = element.text.trim()
if (text.isEmpty()) return null
val name =
when (element) {
is JaiParamName -> text.removePrefix("$$").removePrefix("$")
else -> text
}
return normalizeName(name).takeIf(String::isNotEmpty)
}
private fun normalizeName(name: String): String {
val trimmed = name.trim()
return if (trimmed.length >= 2 && trimmed.first() == '`' && trimmed.last() == '`') {
trimmed.substring(1, trimmed.length - 1)
} else {
trimmed
}
}
}
private fun JaiDirectiveExpr.directiveName(): String? = node.findChildByType(JaiTokenTypes.DIRECTIVE)?.text

View File

@@ -1,6 +1,7 @@
package dev.hgh.jai.settings
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.PathMacroManager
import com.intellij.openapi.components.PersistentStateComponent
import com.intellij.openapi.components.State
import com.intellij.openapi.components.Storage
@@ -62,7 +63,7 @@ class JaiProjectSettings(
val trimmed = rawPath.trim()
if (trimmed.isEmpty()) return null
val expanded =
var expanded =
when {
trimmed == "~" -> {
System.getProperty("user.home")
@@ -76,6 +77,20 @@ class JaiProjectSettings(
trimmed
}
}
// Workspace files may persist project-relative roots using IntelliJ path macros. The
// settings UI normally supplies absolute paths, but loading a project saved elsewhere
// must expand these before the resolver tries to find the VFS root.
expanded = PathMacroManager.getInstance(project).expandPath(expanded)
expanded =
expanded
.replace("\$USER_HOME\$", System.getProperty("user.home"))
.replace("\$USER_HOME", System.getProperty("user.home"))
.let { value ->
val basePath = project.basePath ?: return@let value
value
.replace("\$PROJECT_DIR\$", basePath)
.replace("\$PROJECT_DIR", basePath)
}
val file = File(expanded)
val absolute =
if (file.isAbsolute) {