Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<CompletionParameters>() {
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<String>().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),
Comment on lines +67 to +69
)
}
}
}

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<LookupElement> { 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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = methodIndex.value.keys

private fun buildIndex(): Map<String, List<Method>> {
val index = PhpIndex.getInstance(project)
val result = mutableMapOf<String, MutableList<Method>>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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)

}

Expand Down
8 changes: 8 additions & 0 deletions src/main/resources/META-INF/plugin.xml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@
language="PHP"
implementationClass="io.github.jingu.idea_qiq_plugin.inspection.QiqHelperInspectionSuppressor"/>

<!-- Complete custom helper names (1.x HelperLocator registrations +
2.x/3.x Qiq\Helpers subclass methods) at call positions inside
Qiq templates. Built-in helpers are already completed via the
qiq_runtime.php stub. -->
<completion.contributor
language="PHP"
implementationClass="io.github.jingu.idea_qiq_plugin.completion.QiqHelperCompletionContributor"/>

<!-- ElementManipulators: required for refactorings (Rename, Move, etc.)
to propagate edits made inside the injected PHP back to the Qiq host. -->
<lang.elementManipulator
Expand Down
Original file line number Diff line number Diff line change
@@ -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 `<?php helper ?>` -> ConstantReference.
assertTrue(gateFor(project, "<?php helper;", ConstantReference::class.java))
}

@Test
fun firesOnBareCall(project: Project) {
assertTrue(gateFor(project, "<?php helper();", FunctionReference::class.java))
}

@Test
fun firesOnThisMemberReference(project: Project) {
// `{{ $this->helper }}` -> MemberReference qualified by $this.
assertTrue(gateFor(project, "<?php \$this->helper;", MemberReference::class.java))
}

@Test
fun firesOnThisMethodCall(project: Project) {
assertTrue(gateFor(project, "<?php \$this->helper();", MemberReference::class.java))
}

@Test
fun doesNotFireOnOtherObjectMember(project: Project) {
// `$article->title` is a real property access, not a helper.
assertFalse(gateFor(project, "<?php \$article->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 <T : PsiElement> gateFor(project: Project, php: String, type: Class<T>): 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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -134,7 +134,7 @@ class QiqHelperRegistryTest {
}

@Test
fun acceptsRegisterAndSetFactoryAliases(project: Project) {
fun acceptsSetFactoryAliasButNotUnrelatedRegister(project: Project) {
val map = scan(
project,
"""
Expand All @@ -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
Expand Down
Loading