From 9d861ad62dceea980908813c2862f1ce50eb3f4a Mon Sep 17 00:00:00 2001 From: Yoshitaka Jingu Date: Fri, 29 May 2026 17:05:17 +0900 Subject: [PATCH 1/2] feat: complete custom Qiq helper names in templates Add a PHP CompletionContributor that offers custom helper names at call positions inside Qiq templates (`{{ helperName| }}` / `{{ $this->name| }}`). Names come from the same sources the navigation uses: 1.x HelperLocator registrations (QiqHelperRegistry) and 2.x/3.x Qiq\Helpers subclass methods (QiqHelpersClassResolver). Built-in helpers are already completed via the qiq_runtime.php stub, so only custom names are added; selecting one inserts `()` with the caret inside. Also fold in adjacent cleanup: - Centralize the "is this in a Qiq file" check: QiqReferenceContributor now delegates to QiqInjectionSupport instead of keeping its own copy. - Narrow the 1.x registration method names to `set`/`setFactory` (drop the broad `register`, which could match unrelated calls); update tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../QiqHelperCompletionContributor.kt | 93 +++++++++++++++++++ .../helper/QiqHelperRegistry.kt | 11 ++- .../helper/QiqHelpersClassResolver.kt | 3 + .../navigation/QiqReferenceContributor.kt | 32 +------ src/main/resources/META-INF/plugin.xml | 8 ++ .../helper/QiqHelperRegistryTest.kt | 11 ++- 6 files changed, 120 insertions(+), 38 deletions(-) create mode 100644 src/main/kotlin/io/github/jingu/idea_qiq_plugin/completion/QiqHelperCompletionContributor.kt diff --git a/src/main/kotlin/io/github/jingu/idea_qiq_plugin/completion/QiqHelperCompletionContributor.kt b/src/main/kotlin/io/github/jingu/idea_qiq_plugin/completion/QiqHelperCompletionContributor.kt new file mode 100644 index 0000000..794b672 --- /dev/null +++ b/src/main/kotlin/io/github/jingu/idea_qiq_plugin/completion/QiqHelperCompletionContributor.kt @@ -0,0 +1,93 @@ +package io.github.jingu.idea_qiq_plugin.completion + +import com.intellij.codeInsight.completion.CompletionContributor +import com.intellij.codeInsight.completion.CompletionParameters +import com.intellij.codeInsight.completion.CompletionProvider +import com.intellij.codeInsight.completion.CompletionResultSet +import com.intellij.codeInsight.completion.CompletionType +import com.intellij.codeInsight.completion.InsertHandler +import com.intellij.codeInsight.completion.InsertionContext +import com.intellij.codeInsight.lookup.LookupElement +import com.intellij.codeInsight.lookup.LookupElementBuilder +import com.intellij.patterns.PlatformPatterns +import com.intellij.psi.PsiElement +import com.intellij.util.ProcessingContext +import com.jetbrains.php.lang.psi.elements.FunctionReference +import io.github.jingu.idea_qiq_plugin.helper.QiqHelperRegistry +import io.github.jingu.idea_qiq_plugin.helper.QiqHelpersClassResolver +import io.github.jingu.idea_qiq_plugin.lang.QiqInjectionSupport + +/** + * Completes custom Qiq helper names inside templates. + * + * Built-in helpers are global functions in the bundled qiq_runtime.php stub, + * so PhpStorm already completes them. Custom helpers are not: 1.x helpers are + * registered at runtime via `HelperLocator::set()`, and 2.x/3.x helpers are + * methods on a `Qiq\Helpers` subclass invoked through an untyped `$this`. + * This contributor surfaces those names — the same set the helper navigation + * ([io.github.jingu.idea_qiq_plugin.navigation.QiqHelperGotoDeclarationHandler]) + * can resolve — at call-name positions like `{{ helperName| }}` and + * `{{ $this->helperName| }}`. + */ +class QiqHelperCompletionContributor : CompletionContributor() { + + init { + extend( + CompletionType.BASIC, + // Broad position pattern; the real gating (Qiq file + call-name + // position) happens in the provider where the PSI context is known. + PlatformPatterns.psiElement(), + HelperCompletionProvider(), + ) + } + + private class HelperCompletionProvider : CompletionProvider() { + override fun addCompletions( + parameters: CompletionParameters, + context: ProcessingContext, + result: CompletionResultSet, + ) { + val position = parameters.position + if (!QiqInjectionSupport.isInQiqFile(position)) return + + // Only at a function/method call *name* position. The dummy + // identifier inserted by completion keeps the enclosing reference, + // so the leaf's parent is the (incomplete) FunctionReference / + // MethodReference (the latter extends FunctionReference). + if (position.parent !is FunctionReference) return + + val project = position.project + val names = LinkedHashSet().apply { + addAll(QiqHelperRegistry.getInstance(project).allHelperNames()) + addAll(QiqHelpersClassResolver.getInstance(project).allHelperNames()) + } + if (names.isEmpty()) return + + for (name in names) { + result.addElement( + LookupElementBuilder.create(name) + .withTypeText("Qiq helper", true) + .withInsertHandler(CALL_INSERT_HANDLER), + ) + } + } + } + + private companion object { + // Append `()` and place the caret between the parentheses, unless the + // call already has an opening paren (e.g. re-completing an existing + // call). Mirrors how PhpStorm completes function calls. + private val CALL_INSERT_HANDLER = InsertHandler { context: InsertionContext, _ -> + val editor = context.editor + val document = context.document + val tailOffset = context.tailOffset + val alreadyHasParen = document.charsSequence.let { text -> + tailOffset < text.length && text[tailOffset] == '(' + } + if (!alreadyHasParen) { + document.insertString(tailOffset, "()") + } + editor.caretModel.moveToOffset(tailOffset + 1) + } + } +} diff --git a/src/main/kotlin/io/github/jingu/idea_qiq_plugin/helper/QiqHelperRegistry.kt b/src/main/kotlin/io/github/jingu/idea_qiq_plugin/helper/QiqHelperRegistry.kt index 7b2559b..43ae547 100644 --- a/src/main/kotlin/io/github/jingu/idea_qiq_plugin/helper/QiqHelperRegistry.kt +++ b/src/main/kotlin/io/github/jingu/idea_qiq_plugin/helper/QiqHelperRegistry.kt @@ -242,11 +242,12 @@ class QiqHelperRegistry(private val project: Project) { fun getInstance(project: Project): QiqHelperRegistry = project.getService(QiqHelperRegistry::class.java) - // HelperLocator's public API in Qiq 1.x is `set(name, factory)`. - // `setFactory` and `register` are accepted as common subclass - // additions (e.g. BEAR\QiqModule\HelperLocator only uses `set`, - // but other community wrappers occasionally expose either). - private val REGISTRATION_METHOD_NAMES = setOf("set", "setFactory", "register") + // HelperLocator's registration API: `set(name, factory)` (Qiq 1.x, + // and subclasses such as BEAR\QiqModule\HelperLocator) plus the + // `setFactory` alias some wrappers expose. The broader `register` + // was dropped to avoid matching unrelated `$x->register('name', fn)` + // calls that have nothing to do with Qiq helpers. + private val REGISTRATION_METHOD_NAMES = setOf("set", "setFactory") private val BUILTIN_PSEUDO_TYPES = setOf( "\\void", "\\mixed", "\\never", "\\null", "\\true", "\\false", diff --git a/src/main/kotlin/io/github/jingu/idea_qiq_plugin/helper/QiqHelpersClassResolver.kt b/src/main/kotlin/io/github/jingu/idea_qiq_plugin/helper/QiqHelpersClassResolver.kt index 7bc4e63..6df531f 100644 --- a/src/main/kotlin/io/github/jingu/idea_qiq_plugin/helper/QiqHelpersClassResolver.kt +++ b/src/main/kotlin/io/github/jingu/idea_qiq_plugin/helper/QiqHelpersClassResolver.kt @@ -47,6 +47,9 @@ class QiqHelpersClassResolver(private val project: Project) { fun hasHelper(name: String): Boolean = methodIndex.value.containsKey(name) + /** Every auto-discovered Qiq 2.x/3.x helper name. */ + fun allHelperNames(): Set = methodIndex.value.keys + private fun buildIndex(): Map> { val index = PhpIndex.getInstance(project) val result = mutableMapOf>() diff --git a/src/main/kotlin/io/github/jingu/idea_qiq_plugin/navigation/QiqReferenceContributor.kt b/src/main/kotlin/io/github/jingu/idea_qiq_plugin/navigation/QiqReferenceContributor.kt index 60fbb9e..1222f06 100644 --- a/src/main/kotlin/io/github/jingu/idea_qiq_plugin/navigation/QiqReferenceContributor.kt +++ b/src/main/kotlin/io/github/jingu/idea_qiq_plugin/navigation/QiqReferenceContributor.kt @@ -4,15 +4,12 @@ import com.intellij.lang.injection.InjectedLanguageManager import com.intellij.openapi.util.TextRange import com.intellij.patterns.PlatformPatterns import com.intellij.psi.* -import com.intellij.psi.templateLanguages.TemplateLanguageFileViewProvider import com.intellij.psi.util.PsiTreeUtil import com.intellij.util.ProcessingContext import com.jetbrains.php.lang.psi.elements.FunctionReference import com.jetbrains.php.lang.psi.elements.ParameterList import com.jetbrains.php.lang.psi.elements.StringLiteralExpression -import io.github.jingu.idea_qiq_plugin.lang.QiqFileType -import io.github.jingu.idea_qiq_plugin.lang.QiqFileTypeOverrider -import io.github.jingu.idea_qiq_plugin.lang.QiqTemplateLanguage +import io.github.jingu.idea_qiq_plugin.lang.QiqInjectionSupport import io.github.jingu.idea_qiq_plugin.util.QiqUtil class QiqReferenceContributor : PsiReferenceContributor() { @@ -47,31 +44,8 @@ class QiqReferenceContributor : PsiReferenceContributor() { ) } - private fun isInQiqFile(element: PsiElement): Boolean { - val project = element.project - val ilm = InjectedLanguageManager.getInstance(project) - val topLevel = ilm.getTopLevelFile(element) ?: element.containingFile ?: return false - - val viewProvider = topLevel.viewProvider - if (viewProvider is TemplateLanguageFileViewProvider && viewProvider.baseLanguage == QiqTemplateLanguage) { - return true - } - - if (topLevel.language == QiqTemplateLanguage) { - return true - } - - val fileType = topLevel.virtualFile?.fileType - if (fileType == QiqFileType) { - return true - } - - // 最後のフォールバック: 拡張子チェック - val name = topLevel.virtualFile?.name ?: return false - if (name.endsWith(".qiq") || name.endsWith(".qiq.php")) return true - - return topLevel.virtualFile?.getUserData(QiqFileTypeOverrider.QIQ_MARKER) == true - } + private fun isInQiqFile(element: PsiElement): Boolean = + QiqInjectionSupport.isInQiqFile(element) } diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index fbe57ef..bcbefcc 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -76,6 +76,14 @@ language="PHP" implementationClass="io.github.jingu.idea_qiq_plugin.inspection.QiqHelperInspectionSuppressor"/> + + + new ClassName(...)` * - `function () { return new ClassName(...); }` (no declared return) * - `fn () => new ClassName(...)` (no declared return) - * - `$x->register(...)` and `$x->setFactory(...)` aliases + * - `$x->setFactory(...)` alias (and `register(...)` deliberately ignored) * * Combined, these patterns cover every `$locator->set(...)` call observed * in Hpplus.Spur's `QiqHelperLocatorProvider`. @@ -134,7 +134,7 @@ class QiqHelperRegistryTest { } @Test - fun acceptsRegisterAndSetFactoryAliases(project: Project) { + fun acceptsSetFactoryAliasButNotUnrelatedRegister(project: Project) { val map = scan( project, """ @@ -149,14 +149,17 @@ class QiqHelperRegistryTest { { public function build(HelperLocator ${'$'}locator): void { - ${'$'}locator->register('foo', static fn (): Foo => new Foo()); ${'$'}locator->setFactory('bar', static fn (): Bar => new Bar()); + // `register` is intentionally not a helper-registration + // method, so this must NOT be indexed. + ${'$'}locator->register('foo', static fn (): Foo => new Foo()); } } """.trimIndent(), ) - assertEquals("\\Qiq\\Helper\\Foo", map["foo"]) assertEquals("\\Qiq\\Helper\\Bar", map["bar"]) + assertEquals(null, map["foo"], "register(...) must not be indexed, got: $map") + assertEquals(1, map.size, "Only setFactory should be indexed, got: $map") } @Test From f545eb3e3232f86974e6bcf04d37a99042611b05 Mon Sep 17 00:00:00 2001 From: Yoshitaka Jingu Date: Fri, 29 May 2026 17:22:34 +0900 Subject: [PATCH 2/2] fix: fire helper completion before the call parens Self-review found the position gate only accepted FunctionReference, so completion did not fire at the most common moment: a bare `{{ helper| }}` parses as a ConstantReference (and `{{ $this->helper| }}` as a member reference) until `(` is typed. Widen the gate to bare references (ConstantReference / unqualified FunctionReference) and to member references qualified by `$this`, while still excluding members on other objects (e.g. `$article->title`) to avoid noise. The gate is extracted to a testable companion function with unit tests over in-memory PSI. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../QiqHelperCompletionContributor.kt | 29 +++++-- .../QiqHelperCompletionContributorTest.kt | 80 +++++++++++++++++++ 2 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 src/test/kotlin/io/github/jingu/idea_qiq_plugin/completion/QiqHelperCompletionContributorTest.kt diff --git a/src/main/kotlin/io/github/jingu/idea_qiq_plugin/completion/QiqHelperCompletionContributor.kt b/src/main/kotlin/io/github/jingu/idea_qiq_plugin/completion/QiqHelperCompletionContributor.kt index 794b672..4324bd4 100644 --- a/src/main/kotlin/io/github/jingu/idea_qiq_plugin/completion/QiqHelperCompletionContributor.kt +++ b/src/main/kotlin/io/github/jingu/idea_qiq_plugin/completion/QiqHelperCompletionContributor.kt @@ -12,7 +12,10 @@ import com.intellij.codeInsight.lookup.LookupElementBuilder import com.intellij.patterns.PlatformPatterns import com.intellij.psi.PsiElement import com.intellij.util.ProcessingContext +import com.jetbrains.php.lang.psi.elements.ConstantReference import com.jetbrains.php.lang.psi.elements.FunctionReference +import com.jetbrains.php.lang.psi.elements.MemberReference +import com.jetbrains.php.lang.psi.elements.Variable import io.github.jingu.idea_qiq_plugin.helper.QiqHelperRegistry import io.github.jingu.idea_qiq_plugin.helper.QiqHelpersClassResolver import io.github.jingu.idea_qiq_plugin.lang.QiqInjectionSupport @@ -50,11 +53,7 @@ class QiqHelperCompletionContributor : CompletionContributor() { val position = parameters.position if (!QiqInjectionSupport.isInQiqFile(position)) return - // Only at a function/method call *name* position. The dummy - // identifier inserted by completion keeps the enclosing reference, - // so the leaf's parent is the (incomplete) FunctionReference / - // MethodReference (the latter extends FunctionReference). - if (position.parent !is FunctionReference) return + if (!isHelperCallNamePosition(position.parent)) return val project = position.project val names = LinkedHashSet().apply { @@ -73,7 +72,25 @@ class QiqHelperCompletionContributor : CompletionContributor() { } } - private companion object { + companion object { + /** + * A helper is invoked either as a bare call/name (`{{ helper| }}`, + * which is a [ConstantReference] before the `(` is typed and a + * [FunctionReference] after) or on `$this` (`{{ $this->helper| }}`). + * Member references on any other qualifier (e.g. `$article->title`) + * are not helpers, so they are excluded to avoid noise. + * + * Order matters: [com.jetbrains.php.lang.psi.elements.MethodReference] + * is both a MemberReference and a FunctionReference, so the + * MemberReference branch (with the `$this` check) must come first. + */ + fun isHelperCallNamePosition(parent: PsiElement?): Boolean = when (parent) { + is MemberReference -> (parent.classReference as? Variable)?.name == "this" + is FunctionReference -> true + is ConstantReference -> true + else -> false + } + // Append `()` and place the caret between the parentheses, unless the // call already has an opening paren (e.g. re-completing an existing // call). Mirrors how PhpStorm completes function calls. diff --git a/src/test/kotlin/io/github/jingu/idea_qiq_plugin/completion/QiqHelperCompletionContributorTest.kt b/src/test/kotlin/io/github/jingu/idea_qiq_plugin/completion/QiqHelperCompletionContributorTest.kt new file mode 100644 index 0000000..60a5936 --- /dev/null +++ b/src/test/kotlin/io/github/jingu/idea_qiq_plugin/completion/QiqHelperCompletionContributorTest.kt @@ -0,0 +1,80 @@ +package io.github.jingu.idea_qiq_plugin.completion + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFileFactory +import com.intellij.psi.util.PsiTreeUtil +import com.intellij.testFramework.junit5.RunInEdt +import com.intellij.testFramework.junit5.impl.TestApplicationExtension +import com.intellij.testFramework.junit5.resources.ProjectExtension +import com.jetbrains.php.lang.PhpFileType +import com.jetbrains.php.lang.psi.elements.ConstantReference +import com.jetbrains.php.lang.psi.elements.FunctionReference +import com.jetbrains.php.lang.psi.elements.MemberReference +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.RegisterExtension +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Tests the position gate that decides where helper-name completion fires. + * Uses in-memory PHP PSI (no index needed) to build the reference shapes the + * completion sees as `position.parent`. + */ +@RunInEdt +@ExtendWith(TestApplicationExtension::class) +class QiqHelperCompletionContributorTest { + + companion object { + @JvmField + @RegisterExtension + val projectExtension = ProjectExtension() + } + + @Test + fun firesOnBareNameBeforeParens(project: Project) { + // `{{ helper }}` injects `` -> ConstantReference. + assertTrue(gateFor(project, "helper }}` -> MemberReference qualified by $this. + assertTrue(gateFor(project, "helper;", MemberReference::class.java)) + } + + @Test + fun firesOnThisMethodCall(project: Project) { + assertTrue(gateFor(project, "helper();", MemberReference::class.java)) + } + + @Test + fun doesNotFireOnOtherObjectMember(project: Project) { + // `$article->title` is a real property access, not a helper. + assertFalse(gateFor(project, "title;", MemberReference::class.java)) + } + + @Test + fun doesNotFireOnUnrelatedElement(project: Project) { + assertFalse(QiqHelperCompletionContributor.isHelperCallNamePosition(null)) + } + + /** Parse [php], take the first [type] node, and evaluate the gate — all in a read action. */ + private fun gateFor(project: Project, php: String, type: Class): Boolean { + var result = false + ApplicationManager.getApplication().runReadAction { + val file = PsiFileFactory.getInstance(project) + .createFileFromText("snippet.php", PhpFileType.INSTANCE, php) + val ref = PsiTreeUtil.collectElementsOfType(file, type).first() + result = QiqHelperCompletionContributor.isHelperCallNamePosition(ref) + } + return result + } +}