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,138 @@
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.lookup.LookupElementBuilder
import com.intellij.lang.injection.InjectedLanguageManager
import com.intellij.openapi.progress.ProgressManager
import com.intellij.openapi.vfs.VfsUtilCore
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.patterns.PlatformPatterns
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.QiqInjectionSupport
import io.github.jingu.idea_qiq_plugin.settings.QiqSettingsService

/**
* Completes template paths in the first string argument of Qiq's
* template-referencing calls — `setLayout('...')`, `render('...')`,
* `extends('...')`, `include('...')` — inside Qiq templates.
*
* Candidates are the template files found under
* [QiqSettingsService.resolveTemplateRoots] for the current file, listed as
* root-relative paths with the Qiq extension stripped (so `layout/base.qiq.php`
* is offered as `layout/base`). Pairs with the existing Go to Declaration on
* the same arguments.
*/
class QiqTemplatePathCompletionContributor : CompletionContributor() {

init {
extend(
CompletionType.BASIC,
PlatformPatterns.psiElement(),
TemplatePathCompletionProvider(),
)
}

private class TemplatePathCompletionProvider : CompletionProvider<CompletionParameters>() {
override fun addCompletions(
parameters: CompletionParameters,
context: ProcessingContext,
result: CompletionResultSet,
) {
val position = parameters.position
if (!QiqInjectionSupport.isInQiqFile(position)) return

val stringLiteral = PsiTreeUtil.getParentOfType(position, StringLiteralExpression::class.java) ?: return
if (!isFirstArgOfTemplateCall(stringLiteral)) return

val project = position.project
val ilm = InjectedLanguageManager.getInstance(project)
val contextFile = (ilm.getTopLevelFile(parameters.originalFile) ?: parameters.originalFile)
.virtualFile ?: return

val settings = QiqSettingsService.getInstance(project)
val roots = settings.resolveTemplateRoots(contextFile)
if (roots.isEmpty()) return
val extensions = settings.state.candidateExtensions

// Replace the already-typed in-quote text, so `layout/ba` is
// matched/replaced rather than matching against the whole literal.
val typedPrefix = inQuotePrefix(stringLiteral, parameters.offset) ?: return
val pathResult = result.withPrefixMatcher(typedPrefix)

val paths = LinkedHashSet<String>()
for (root in roots) {
collectTemplatePaths(root, root, extensions, paths)
}
for (path in paths) {
pathResult.addElement(
LookupElementBuilder.create(path).withTypeText("Qiq template", true),
)
}
}

private fun isFirstArgOfTemplateCall(stringLiteral: StringLiteralExpression): Boolean {
val parameterList = stringLiteral.parent as? ParameterList ?: return false
val call = parameterList.parent as? FunctionReference ?: return false
if (call.name !in TEMPLATE_FUNCTIONS) return false
return parameterList.parameters.firstOrNull() === stringLiteral
}

/** The in-quote text before the caret, excluding the surrounding quote. */
private fun inQuotePrefix(stringLiteral: StringLiteralExpression, caretOffset: Int): String? {
val caretInLiteral = caretOffset - stringLiteral.textRange.startOffset
val text = stringLiteral.text
// index 0 is the opening quote; require the caret to sit past it.
if (caretInLiteral < 1 || caretInLiteral > text.length) return null
return text.substring(1, caretInLiteral)
}

private fun collectTemplatePaths(
dir: VirtualFile,
root: VirtualFile,
extensions: List<String>,
out: MutableSet<String>,
) {
if (out.size >= MAX_CANDIDATES) return
for (child in dir.children) {
ProgressManager.checkCanceled()
if (out.size >= MAX_CANDIDATES) return
if (child.isDirectory) {
collectTemplatePaths(child, root, extensions, out)
} else {
val relative = VfsUtilCore.getRelativePath(child, root) ?: continue
stripTemplateExtension(relative, extensions)?.let(out::add)
}
}
}
}

companion object {
private const val MAX_CANDIDATES = 2000
private val TEMPLATE_FUNCTIONS = setOf("setLayout", "render", "extends", "include")

/**
* Strip the first matching Qiq [extensions] entry from [relativePath],
* or return null when the file is not a template. [extensions] is
* checked in order, so list longer suffixes first (e.g. `.qiq.php`
* before `.php`) to avoid leaving a dangling `.qiq`.
*
* Pure helper, unit-tested independently of the VFS walk.
*/
fun stripTemplateExtension(relativePath: String, extensions: List<String>): String? {
for (ext in extensions) {
if (relativePath.endsWith(ext, ignoreCase = true)) {
return relativePath.dropLast(ext.length)
}
}
return null
}
}
}
7 changes: 7 additions & 0 deletions src/main/resources/META-INF/plugin.xml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,13 @@
language="PHP"
implementationClass="io.github.jingu.idea_qiq_plugin.completion.QiqHelperCompletionContributor"/>

<!-- Complete template paths in setLayout()/render()/extends()/include()
arguments, listing files under the resolved template roots with the
Qiq extension stripped. Pairs with the path Go to Declaration. -->
<completion.contributor
language="PHP"
implementationClass="io.github.jingu.idea_qiq_plugin.completion.QiqTemplatePathCompletionContributor"/>

<!-- 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,53 @@
package io.github.jingu.idea_qiq_plugin.completion

import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull

/**
* Unit tests for the pure extension-stripping helper that turns a
* root-relative template file path into the path users type in
* `setLayout('...')` / `render('...')` etc.
*
* The VFS walk and completion firing are exercised manually / by the
* planned HeavyPlatformTestCase integration suite.
*/
class QiqTemplatePathCompletionContributorTest {

private val exts = listOf(".qiq.php", ".qiq", ".php")

@Test
fun stripsLongestMatchingExtensionFirst() {
// `.qiq.php` must win over `.php`, otherwise a dangling `.qiq` is left.
assertEquals(
"layout/base",
QiqTemplatePathCompletionContributor.stripTemplateExtension("layout/base.qiq.php", exts),
)
}

@Test
fun stripsPlainQiqAndPhpExtensions() {
assertEquals("partials/menu", QiqTemplatePathCompletionContributor.stripTemplateExtension("partials/menu.qiq", exts))
assertEquals("page/index", QiqTemplatePathCompletionContributor.stripTemplateExtension("page/index.php", exts))
}

@Test
fun returnsNullForNonTemplateFiles() {
assertNull(QiqTemplatePathCompletionContributor.stripTemplateExtension("assets/app.css", exts))
assertNull(QiqTemplatePathCompletionContributor.stripTemplateExtension("README.md", exts))
}

@Test
fun isCaseInsensitiveOnExtension() {
assertEquals("Page/Home", QiqTemplatePathCompletionContributor.stripTemplateExtension("Page/Home.PHP", exts))
}

@Test
fun respectsConfiguredExtensionOrder() {
// With only ".php" configured, a ".qiq.php" file keeps its ".qiq" stem.
assertEquals(
"layout/base.qiq",
QiqTemplatePathCompletionContributor.stripTemplateExtension("layout/base.qiq.php", listOf(".php")),
)
}
}