diff --git a/src/main/kotlin/io/github/jingu/idea_qiq_plugin/navigation/QiqInjectedRenameHandler.kt b/src/main/kotlin/io/github/jingu/idea_qiq_plugin/navigation/QiqInjectedRenameHandler.kt new file mode 100644 index 0000000..3e6352e --- /dev/null +++ b/src/main/kotlin/io/github/jingu/idea_qiq_plugin/navigation/QiqInjectedRenameHandler.kt @@ -0,0 +1,112 @@ +package io.github.jingu.idea_qiq_plugin.navigation + +import com.intellij.lang.injection.InjectedLanguageManager +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.PsiNameIdentifierOwner +import com.intellij.psi.PsiNamedElement +import com.intellij.psi.PsiReference +import com.intellij.refactoring.rename.PsiElementRenameHandler +import io.github.jingu.idea_qiq_plugin.lang.QiqTemplateLanguage + +/** + * Makes Shift+F6 work when the caret sits on a PHP identifier inside a Qiq + * injection host such as `{{h $article->title }}` or ``. + * + * The platform's default PsiElementRenameHandler reads + * CommonDataKeys.PSI_ELEMENT from the data context, which is set to the + * outer Qiq host (an unnamed wrapper) — that satisfies neither + * PsiNamedElement nor the renameability checks, so the action stays + * disabled for property references. Local variable rename happens to work + * via the default handler because variables expose themselves as + * PsiNamedElement reachable through other data keys, but property / + * method accesses do not. + * + * This handler descends into the injected fragment via + * InjectedLanguageManager, then walks parents and asks PHP's own + * references to resolve themselves. The first reference whose resolution + * is a PsiNameIdentifierOwner becomes the rename target. PHP's type + * inference already has the injected context wired by our + * MultiHostInjector, so resolution covers variables, properties, methods + * and class references uniformly. + * + * Scope: the handler only fires when the top-level (host) file's language + * is Qiq, so it does not compete with PHP's own handlers in plain `.php` + * files. The platform pre-narrows PSI_FILE in the data context to the + * most specific file at the caret (sometimes the injected PHP file, + * sometimes the outer Qiq file); the host check normalizes both cases. + */ +class QiqInjectedRenameHandler : PsiElementRenameHandler() { + + override fun isAvailableOnDataContext(dataContext: DataContext): Boolean = + findInjectedTarget(dataContext) != null + + override fun isRenaming(dataContext: DataContext): Boolean = + isAvailableOnDataContext(dataContext) + + override fun invoke(project: Project, editor: Editor?, file: PsiFile?, dataContext: DataContext) { + val target = findInjectedTarget(dataContext) ?: return + invoke(project, arrayOf(target), dataContext) + } + + private fun findInjectedTarget(ctx: DataContext): PsiElement? { + val editor = CommonDataKeys.EDITOR.getData(ctx) ?: return null + val file = CommonDataKeys.PSI_FILE.getData(ctx) ?: return null + val offset = editor.caretModel.offset + + val ilm = InjectedLanguageManager.getInstance(file.project) + val hostFile = ilm.getTopLevelFile(file) + if (hostFile.language !is QiqTemplateLanguage) return null + + val injectedElement = if (file !== hostFile) { + file.findElementAt(offset) + } else { + ilm.findInjectedElementAt(file, offset) + } ?: return null + + return resolveRenameTarget(injectedElement) + } + + private fun resolveRenameTarget(start: PsiElement): PsiNamedElement? { + var current: PsiElement? = start + var depth = 0 + while (current != null && current !is PsiFile && depth <= MAX_WALK_DEPTH) { + resolveAcrossReferences(current)?.let { return it } + if (current is PsiNameIdentifierOwner && + current.nameIdentifier != null && + (current as PsiNamedElement).name != null + ) { + return current + } + current = current.parent + depth++ + } + return null + } + + private fun resolveAcrossReferences(element: PsiElement): PsiNamedElement? { + if (element is PsiReference) { + (element as PsiReference).resolve()?.let { resolved -> + if (resolved is PsiNamedElement && resolved != element && resolved.name != null) { + return resolved + } + } + } + for (ref in element.references) { + val resolved = ref.resolve() + if (resolved is PsiNamedElement && resolved != element && resolved.name != null) { + return resolved + } + } + return null + } + + private companion object { + // Safety cap so a degenerate PSI cannot trap us in a tight loop. + const val MAX_WALK_DEPTH = 12 + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 6e8b366..fdaad80 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -62,6 +62,12 @@ + + +