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..4324bd4 --- /dev/null +++ b/src/main/kotlin/io/github/jingu/idea_qiq_plugin/completion/QiqHelperCompletionContributor.kt @@ -0,0 +1,110 @@ +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.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 + +/** + * 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 + + if (!isHelperCallNamePosition(position.parent)) 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), + ) + } + } + } + + 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. + 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"/> + + + ` -> 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 + } +} diff --git a/src/test/kotlin/io/github/jingu/idea_qiq_plugin/helper/QiqHelperRegistryTest.kt b/src/test/kotlin/io/github/jingu/idea_qiq_plugin/helper/QiqHelperRegistryTest.kt index 3f10367..95783ca 100644 --- a/src/test/kotlin/io/github/jingu/idea_qiq_plugin/helper/QiqHelperRegistryTest.kt +++ b/src/test/kotlin/io/github/jingu/idea_qiq_plugin/helper/QiqHelperRegistryTest.kt @@ -22,7 +22,7 @@ import kotlin.test.assertEquals * - `static fn (): ClassName => 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