From 4f3884a8f54876b11cdfcb459abb9bfdf010da3a Mon Sep 17 00:00:00 2001 From: Yoshitaka Jingu Date: Fri, 29 May 2026 17:10:29 +0900 Subject: [PATCH] feat: complete template paths in Qiq template-referencing calls Add a PHP CompletionContributor that, inside a Qiq template, completes the first string argument of setLayout()/render()/extends()/include() with the template files found under the resolved template roots (QiqSettingsService.resolveTemplateRoots), listed as root-relative paths with the Qiq extension stripped (layout/base.qiq.php -> layout/base). Pairs with the existing Go to Declaration on the same arguments. The extension-stripping logic is a pure companion function (stripTemplateExtension) with unit tests; the VFS walk and completion firing are left to manual / HeavyPlatformTestCase verification. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../QiqTemplatePathCompletionContributor.kt | 138 ++++++++++++++++++ src/main/resources/META-INF/plugin.xml | 7 + ...iqTemplatePathCompletionContributorTest.kt | 53 +++++++ 3 files changed, 198 insertions(+) create mode 100644 src/main/kotlin/io/github/jingu/idea_qiq_plugin/completion/QiqTemplatePathCompletionContributor.kt create mode 100644 src/test/kotlin/io/github/jingu/idea_qiq_plugin/completion/QiqTemplatePathCompletionContributorTest.kt diff --git a/src/main/kotlin/io/github/jingu/idea_qiq_plugin/completion/QiqTemplatePathCompletionContributor.kt b/src/main/kotlin/io/github/jingu/idea_qiq_plugin/completion/QiqTemplatePathCompletionContributor.kt new file mode 100644 index 0000000..f7da9c7 --- /dev/null +++ b/src/main/kotlin/io/github/jingu/idea_qiq_plugin/completion/QiqTemplatePathCompletionContributor.kt @@ -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() { + 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() + 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, + out: MutableSet, + ) { + 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? { + for (ext in extensions) { + if (relativePath.endsWith(ext, ignoreCase = true)) { + return relativePath.dropLast(ext.length) + } + } + return null + } + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index bcbefcc..edb7d0d 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -84,6 +84,13 @@ language="PHP" implementationClass="io.github.jingu.idea_qiq_plugin.completion.QiqHelperCompletionContributor"/> + + +