From ec5396768dee0d2f3e1ec11e59875d39b459883c Mon Sep 17 00:00:00 2001 From: Yoshitaka Jingu Date: Tue, 26 May 2026 11:24:31 +0900 Subject: [PATCH] feat: enable Shift+F6 rename inside Qiq injected fragments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing Shift+F6 on a PHP identifier inside a Qiq template (e.g. the property name in `{{h $article->title }}` or any identifier in an inline `` host) did not open the rename dialog. Local variable rename happened to work via the default handler, but property and method accesses silently failed because CommonDataKeys.PSI_ELEMENT resolves to the outer Qiq host (an unnamed wrapper) and the default PsiElementRenameHandler.canRename rejects it. Add QiqInjectedRenameHandler, registered via : - It scopes itself by checking that the top-level (host) file is Qiq, using InjectedLanguageManager.getTopLevelFile. 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 — so the scope check normalizes both shapes. - It resolves the rename target by descending into the injected fragment via InjectedLanguageManager.findInjectedElementAt, walking parents, and asking each PSI element (or its references) to resolve to a PsiNameIdentifierOwner. This handles variables, properties, methods and class references uniformly through PHP's own resolution. - It then hands the resolved target off to PsiElementRenameHandler's standard pipeline, which in turn uses the host's ElementManipulator (added in the previous PR) to rewrite the Qiq host text. Combined with the existing PHP→Qiq propagation, rename refactoring now flows bidirectionally between Qiq templates and PHP classes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../navigation/QiqInjectedRenameHandler.kt | 112 ++++++++++++++++++ src/main/resources/META-INF/plugin.xml | 6 + 2 files changed, 118 insertions(+) create mode 100644 src/main/kotlin/io/github/jingu/idea_qiq_plugin/navigation/QiqInjectedRenameHandler.kt 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 @@ + + +