From 484654c1d11e4b4d310b1cffd1dba3f4af7b7146 Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Sun, 20 Sep 2026 23:23:03 -0400 Subject: [PATCH 01/20] 1.0.0-alpha.3 --- CHANGELOG.md | 36 +++- CONTRIBUTING.md | 11 +- README.md | 8 +- TUTORIAL.md | 7 +- build.gradle.kts | 11 +- gradle.properties | 4 +- .../xoops/support/XoopsProjectService.java | 3 + .../xoops/support/XoopsStartupActivity.java | 14 +- .../actions/ShowXoopsProjectInfoAction.java | 2 +- .../XoopsLanguageConstantParser.java | 70 +++++++ .../XoopsLanguageConstantReference.java | 40 ++++ ...sLanguageConstantReferenceContributor.java | 56 +++++ .../XoopsLanguageConstantsCache.java | 150 ++++++++------ .../InsertBeforeStatementQuickFix.java | 86 ++++++++ .../InsertRootPathGuardQuickFix.java | 40 ++-- .../support/inspections/PhpTextUtil.java | 13 ++ .../inspections/RegisterTemplateQuickFix.java | 74 +++++++ .../inspections/ReplacePsiTextQuickFix.java | 48 +++++ .../XoopsDeprecatedDbApiInspection.java | 2 +- ...XoopsDeprecatedUnicodeDtypeInspection.java | 73 +++++++ .../XoopsIncludeOnceHeaderInspection.java | 2 +- ...psMissingRegisteredTemplateInspection.java | 2 +- .../inspections/XoopsQueryExecInspection.java | 2 +- .../XoopsResultSetGuardInspection.java | 134 +++++++----- .../XoopsRootPathGuardInspection.java | 66 ++---- .../inspections/XoopsRootPathGuardPolicy.java | 195 ++++++++++++++++++ .../XoopsSuperglobalInspection.java | 2 +- .../XoopsUnregisteredTemplateInspection.java | 113 ++++++++++ .../XoopsWrongSmartyDelimiterInspection.java | 2 +- .../{CoreProfile.java => CoreVersion.java} | 6 +- .../support/scanner/XoopsProjectReport.java | 2 +- .../support/scanner/XoopsProjectScanner.java | 117 +++++++---- .../support/settings/XoopsConfigurable.java | 18 +- .../support/settings/XoopsSettingsState.java | 2 +- .../support/ui/XoopsReportHtmlRenderer.java | 2 +- src/main/resources/META-INF/plugin.xml | 44 +++- src/main/resources/META-INF/pluginIcon.svg | 36 +++- src/main/resources/icons/toolWindowXoops.svg | 36 +++- .../XoopsDeprecatedUnicodeDtype.html | 14 ++ .../XoopsResultSetGuard.html | 14 +- .../XoopsRootPathGuard.html | 18 +- .../XoopsUnregisteredTemplate.html | 10 + .../xoops/support/PluginDescriptorTest.java | 33 +++ .../XoopsLanguageConstantParserTest.java | 45 ++++ .../XoopsDeprecatedUnicodeDtypeTest.java | 19 ++ .../XoopsResultSetGuardInspectionTest.java | 89 ++++++++ .../XoopsRootPathGuardPolicyTest.java | 118 +++++++++++ ...opsUnregisteredTemplateInspectionTest.java | 28 +++ .../scanner/XoopsProjectScannerTest.java | 105 ++++++++++ test-fixtures/index_404_stub.php | 2 + test-fixtures/wgsimpleacc_shapes.php | 35 ++++ whats-new.html | 14 ++ 52 files changed, 1776 insertions(+), 297 deletions(-) create mode 100644 src/main/java/org/xoops/support/completion/XoopsLanguageConstantParser.java create mode 100644 src/main/java/org/xoops/support/completion/XoopsLanguageConstantReference.java create mode 100644 src/main/java/org/xoops/support/completion/XoopsLanguageConstantReferenceContributor.java create mode 100644 src/main/java/org/xoops/support/inspections/InsertBeforeStatementQuickFix.java create mode 100644 src/main/java/org/xoops/support/inspections/RegisterTemplateQuickFix.java create mode 100644 src/main/java/org/xoops/support/inspections/ReplacePsiTextQuickFix.java create mode 100644 src/main/java/org/xoops/support/inspections/XoopsDeprecatedUnicodeDtypeInspection.java create mode 100644 src/main/java/org/xoops/support/inspections/XoopsRootPathGuardPolicy.java create mode 100644 src/main/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspection.java rename src/main/java/org/xoops/support/scanner/{CoreProfile.java => CoreVersion.java} (81%) create mode 100644 src/main/resources/inspectionDescriptions/XoopsDeprecatedUnicodeDtype.html create mode 100644 src/main/resources/inspectionDescriptions/XoopsUnregisteredTemplate.html create mode 100644 src/test/java/org/xoops/support/PluginDescriptorTest.java create mode 100644 src/test/java/org/xoops/support/completion/XoopsLanguageConstantParserTest.java create mode 100644 src/test/java/org/xoops/support/inspections/XoopsDeprecatedUnicodeDtypeTest.java create mode 100644 src/test/java/org/xoops/support/inspections/XoopsResultSetGuardInspectionTest.java create mode 100644 src/test/java/org/xoops/support/inspections/XoopsRootPathGuardPolicyTest.java create mode 100644 src/test/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspectionTest.java create mode 100644 src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java create mode 100644 test-fixtures/index_404_stub.php create mode 100644 test-fixtures/wgsimpleacc_shapes.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 74d0098..aa031fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,36 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project uses [Semantic Versioning](https://semver.org/) with pre-release tags (`1.0.0-alpha.N`). +## Unreleased + +### Fixed + +- Replaced deprecated `StartupActivity` with `ProjectActivity`. +- `FilenameIndex.getVirtualFilesByName` now passes `Project` (the two-arg overload is deprecated). +- Marketplace Plugin Verifier **Critical** on IntelliJ IDEA is the missing `com.jetbrains.php` plugin in IU, not a PhpStorm incompatibility — verify against PhpStorm. + +## [1.0.0-alpha.3] — 1.0.0 Alpha 3 — 2026-09-02 + +### Fixed (Goffy / wgSimpleAcc field report) + +- Inspections no longer report every finding twice (file-level `visitFile` now runs only on the view-provider's primary PSI file). +- `isResultSet` quick-fix inserts before the current PSI statement (Inspect Code batch apply no longer no-ops after the first click in a file). +- An early-exit `isResultSet` / `mysqli_result` guard covers later `fetch*` of the same variable, including `while (list(...) = $db->fetchRow($result))`. +- ROOT_PATH guard: `die` and `exit` after `namespace` / `use` are recognized; the quick-fix inserts *after* the namespace (never before — invalid PHP). +- ROOT_PATH guard is not required on 404/403 directory stubs, `admin/` CP scripts, `xoops_version.php`, or files whose first work is including `mainfile.php` / `header.php` / `admin_header.php`. + +### Added + +- Language-constant completion scans every `language/**/*.php` (not a five-name allowlist). +- Ctrl+B / Find Usages on `_MI_` / `_AM_` / `_MD_` / `_CO_` / `_MB_` constants (resolves to `define()` in `language/english/` when present). +- Inspection: `.tpl` on disk under `templates/` or `blocks/` not listed in `xoops_version.php` (inverse of missing registered template), with a register-in-manifest quick-fix. +- Inspection: `XOBJ_DTYPE_UNICODE_*` deprecated since 2.7.3, rename quick-fix to the non-UNICODE successor (silent when Core Version is 2.5). +- JUnit 5 analyzer / policy / scanner / plugin.xml tests. + +### Changed + +- Plugin version is taken only from `gradle.properties` (`plugin.xml` no longer hard-codes ``). + ## [1.0.0-alpha.2] — 1.0.0 Alpha 2 — 2026-08-12 ### Fixed @@ -23,7 +53,7 @@ and this project uses [Semantic Versioning](https://semver.org/) with pre-releas ## [1.0.0-alpha.1] — 1.0.0 Alpha 1 — 2026-08-11 -First public alpha of **XOOPS Support** — a PhpStorm / IntelliJ helper for XOOPS 2.5 / 2.7 / 4.0 module and core work. +First public alpha of **XOOPS Support** — a PhpStorm / IntelliJ helper for XOOPS 2.5 / 2.7 / 4.0 Core and module development. Early preview: APIs, inspections, and quick fixes may change before a stable 1.0. @@ -44,7 +74,7 @@ Early preview: APIs, inspections, and quick fixes may change before a stable 1.0 - Wrong Smarty delimiters (XOOPS `<{ … }>` vs bare `{ … }`) - **Live templates** — `xoguard`, `xofetch`, `xofetchdb`, `xohead`, `xolang`, `xocriteria`, `xorequest`, `xoexec` - **Language-constant completion** — `_MI_` / `_AM_` / `_MD_` / … from `language/**/*.php`, with project cache and VFS invalidation -- **Settings** — enable/disable, suppress startup notification, core profile, table prefix +- **Settings** — enable/disable, suppress startup notification, Core Version, table prefix - **Dynamic plugin** — no `require-restart`; install / disable / enable without IDE restart when unload succeeds - **CI / release** — GitHub Actions (`check`, `verifyPlugin`, `buildPlugin`); tag `v*` must match `pluginVersion` - **Compatibility** — PhpStorm **2024.3+** (`since-build=243`, open-ended `until-build` for 2025.x / 2026.2.x) @@ -56,4 +86,6 @@ Early preview: APIs, inspections, and quick fixes may change before a stable 1.0 - Overview scans are sequenced so a slower older scan cannot overwrite a newer refresh - License: GPL-2.0 (SPDX **GPL-2.0-or-later** in packaging docs) +[1.0.0-alpha.3]: https://github.com/XOOPS/phpstorm-plugin/releases/tag/v1.0.0-alpha.3 +[1.0.0-alpha.2]: https://github.com/XOOPS/phpstorm-plugin/releases/tag/v1.0.0-alpha.2 [1.0.0-alpha.1]: https://github.com/XOOPS/phpstorm-plugin/releases/tag/v1.0.0-alpha.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cd68014..7a489ad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,6 +24,9 @@ Thanks for helping improve the PhpStorm plugin for XOOPS developers. - Java 21, package root `org.xoops.support`. - Keep inspections **fast and heuristic** unless a full PSI analysis is clearly worth it. +- File-level `visitFile` inspections must call `PhpTextUtil.isPrimaryPsiFile` (PHP+HTML dual PSI). +- Quick-fixes compute ranges from the PSI element at apply-time, not frozen offsets. +- Tests are **JUnit 4** (`org.junit.Test`, public classes/methods) via `testFramework(TestFrameworkType.Platform)`. Jupiter will not start the IPG executor. - Prefer **quick fixes** that are safe and local (single file / small edit). - Do not hard-code AI vendor names or branding in UI strings. - Index / PSI access from background threads must use `ReadAction` (see `XoopsProjectService`). @@ -38,11 +41,11 @@ CI runs the same command set. ## Version bumps -1. Update `pluginVersion` in `gradle.properties`. -2. Update `` and change-notes in `src/main/resources/META-INF/plugin.xml`. -3. Update `CHANGELOG.md` and `whats-new.html`. +1. Update `pluginVersion` in `gradle.properties` (this is the single source; do not add `` to `plugin.xml`). +2. Update change-notes in `src/main/resources/META-INF/plugin.xml`. +3. Update `CHANGELOG.md` and `whats-new.html` (and the version table in `README.md`). 4. Tag release with the **same** version as `pluginVersion` in `gradle.properties`, e.g. - `git tag v1.0.0-alpha.1 && git push origin v1.0.0-alpha.1` (triggers release workflow). + `git tag v1.0.0-alpha.3 && git push origin v1.0.0-alpha.3` (triggers release workflow). ## Reporting issues diff --git a/README.md b/README.md index 26fcfcf..4ef295c 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,15 @@ It brings XOOPS conventions into the IDE: inspections with Alt+Enter quick fixes | --- | --- | | Repository | [github.com/XOOPS/phpstorm-plugin](https://github.com/XOOPS/phpstorm-plugin) | | Plugin id | `org.xoops.plugin.support` | -| Version | **1.0.0 Alpha 1** (`1.0.0-alpha.1`) | +| Version | **1.0.0 Alpha 3** (`1.0.0-alpha.3`) | | Compatibility | PhpStorm **2024.3+** (since-build `243`, no upper cap — includes **2026.2.x**) | | License | [GPL-2.0-or-later](LICENSE) | ## Features -- **Inspections + quick fixes** — root-path guards, `isResultSet` before `fetch*`, `query()` vs `exec()`, deprecated `queryF`/`quoteString`, missing registered templates, wrong Smarty delimiters, `include` → `include_once` for headers. For input: **keyed** `$_GET`/`$_POST`/`$_COOKIE['key']` offer `\Xmf\Request::getString` fixes; **bare** `$_GET`/`$_POST`/`$_REQUEST`/`$_COOKIE` and **keyed `$_REQUEST`** are warnings only (no auto-fix when the source is ambiguous) +- **Inspections + quick fixes** — root-path guards (namespaced files; 404/entry-point skips), `isResultSet` before `fetch*` (including `while (list = fetchRow)`), `query()` vs `exec()`, deprecated `queryF`/`quoteString` and `XOBJ_DTYPE_UNICODE_*`, missing *and unregistered* templates, wrong Smarty delimiters, `include` → `include_once` for headers. For input: **keyed** `$_GET`/`$_POST`/`$_COOKIE['key']` offer `\Xmf\Request::getString` fixes; **bare** `$_GET`/`$_POST`/`$_REQUEST`/`$_COOKIE` and **keyed `$_REQUEST`** are warnings only (no auto-fix when the source is ambiguous) - **Live templates** — `xoguard`, `xofetch`, `xofetchdb`, `xohead`, `xolang`, `xocriteria`, `xorequest`, `xoexec` -- **Language constants** — completion for `_MI_` / `_AM_` / `_MD_` / … from `language/**/*.php` +- **Language constants** — completion and Ctrl+B for `_MI_` / `_AM_` / `_MD_` / `_CO_` / `_MB_` from every `language/**/*.php` - **Project tools** — detection balloon, scanner tool window, **Tools → XOOPS Support** - **Module scaffold** — legacy or hybrid (PSR-4 / composer) via **New → XOOPS Module…** @@ -81,7 +81,7 @@ xoops-support/ Edit `gradle.properties` for version and platform target: ```properties -pluginVersion=1.0.0-alpha.1 +pluginVersion=1.0.0-alpha.3 platformVersion=2024.3.5 pluginSinceBuild=243 pluginUntilBuild= # empty = open-ended (2026.2+) diff --git a/TUTORIAL.md b/TUTORIAL.md index b7ff7a8..5197f75 100644 --- a/TUTORIAL.md +++ b/TUTORIAL.md @@ -22,7 +22,7 @@ Then **Install Plugin from Disk…** → `build/distributions/xoops-support-*.zi 1. Open a **real XOOPS install** (with `mainfile.php` or `htdocs/mainfile.php`). This plugin repository alone is not a XOOPS site root. 2. Optional balloon: “XOOPS Support active”. 3. **Tools → XOOPS Support → Show XOOPS Project Info** (runs in the background). -4. **Settings** (search “XOOPS Support”): enable/disable, profile, suppress notification. +4. **Settings** (search “XOOPS Support”): enable/disable, Core Version, suppress notification. ## 3. Tool window / scanner @@ -36,6 +36,7 @@ Then **Install Plugin from Disk…** → `build/distributions/xoops-support-*.zi 2. Open it — highlights for guards, query/exec, Request, etc. 3. **Alt+Enter** on each highlight and apply the fix. 4. For Smarty: a `.tpl` with bare `{if …}` should offer delimiter conversion. +5. Goffy / wgSimpleAcc shapes: `test-fixtures/wgsimpleacc_shapes.php` (namespaced `die` guard + `while (list = fetchRow)`) and `test-fixtures/index_404_stub.php` (must **not** warn). ## 5. Live templates @@ -54,7 +55,7 @@ In a PHP file, type the abbreviation and press **Tab**: ## 6. Language constants -Type `_MI_` (or `_AM_`, `_MD_`, …) and **Ctrl+Space** — completions from `language/**/*.php` defines. +Type `_MI_` (or `_AM_`, `_MD_`, `_CO_`, `_MB_`) and **Ctrl+Space** — completions from every `language/**/*.php` `define()`. **Ctrl+B** (Go to Declaration) on a constant jumps to the `define()` (prefers `language/english/`). ## 7. New module @@ -67,7 +68,7 @@ Type `_MI_` (or `_AM_`, `_MD_`, …) and **Ctrl+Space** — completions from `la | Action | Purpose | | --- | --- | -| Show XOOPS Project Info | Dialog: profile, web root, modules | +| Show XOOPS Project Info | Dialog: Core Version, web root, modules | | Refresh XOOPS Overview | Rescan + tool window | | New XOOPS Module Stub… | Scaffold module | diff --git a/build.gradle.kts b/build.gradle.kts index f2c18c5..953d40f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,5 @@ import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType +import org.jetbrains.intellij.platform.gradle.TestFrameworkType fun prop(key: String): String = providers.gradleProperty(key).orNull @@ -24,7 +25,9 @@ dependencies { phpstorm(prop("platformVersion")) bundledPlugin("com.jetbrains.php") pluginVerifier() + testFramework(TestFrameworkType.Platform) } + testImplementation("junit:junit:4.13.2") } java { @@ -57,8 +60,8 @@ intellijPlatform { pluginVerification { ides { - // Same PhpStorm we compile against — keeps CI time/bandwidth predictable. - // Expand with recommended() / select { ... } when you want a wider matrix. + // PhpStorm only. Verifying IntelliJ IDEA (IU) without the PHP plugin + // yields a false Critical: "missing mandatory dependency com.jetbrains.php". create(IntelliJPlatformType.PhpStorm, prop("platformVersion")) } } @@ -74,6 +77,10 @@ tasks { gradleVersion = prop("gradleVersion") } + test { + useJUnit() + } + runIde { autoReload = true jvmArgumentProviders += CommandLineArgumentProvider { diff --git a/gradle.properties b/gradle.properties index 1f139f7..c7b54bb 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,8 +5,8 @@ pluginGroup=org.xoops pluginName=xoops-support pluginId=org.xoops.plugin.support -# Technical version (ZIP / Marketplace). Display: "1.0.0 Alpha 2" -pluginVersion=1.0.0-alpha.2 +# Technical version (ZIP / Marketplace). Display: "1.0.0 Alpha 3" +pluginVersion=1.0.0-alpha.3 # Platform / compatibility (PhpStorm build numbers) # 243 = 2024.3; leave until open so 2025.x / 2026.x (e.g. 2026.2.1 = 262.*) install cleanly. diff --git a/src/main/java/org/xoops/support/XoopsProjectService.java b/src/main/java/org/xoops/support/XoopsProjectService.java index 5f3f9e7..39df2e7 100644 --- a/src/main/java/org/xoops/support/XoopsProjectService.java +++ b/src/main/java/org/xoops/support/XoopsProjectService.java @@ -53,6 +53,7 @@ public boolean isXoopsProject() { @RequiresReadLock private @Nullable VirtualFile findMainfileUnderReadLock() { Collection files = FilenameIndex.getVirtualFilesByName( + project, "mainfile.php", GlobalSearchScope.projectScope(project) ); @@ -78,6 +79,7 @@ public boolean isXoopsProject() { @RequiresReadLock private @NotNull List findXoopsVersionFilesUnderReadLock() { Collection files = FilenameIndex.getVirtualFilesByName( + project, "xoops_version.php", GlobalSearchScope.projectScope(project) ); @@ -121,6 +123,7 @@ public boolean isXoopsProject() { @RequiresReadLock private @Nullable VirtualFile findXoopsLibUnderReadLock() { Collection dirs = FilenameIndex.getVirtualFilesByName( + project, "xoops_lib", GlobalSearchScope.projectScope(project) ); diff --git a/src/main/java/org/xoops/support/XoopsStartupActivity.java b/src/main/java/org/xoops/support/XoopsStartupActivity.java index 4e0e201..f2e5118 100644 --- a/src/main/java/org/xoops/support/XoopsStartupActivity.java +++ b/src/main/java/org/xoops/support/XoopsStartupActivity.java @@ -6,8 +6,11 @@ import com.intellij.openapi.application.ModalityState; import com.intellij.openapi.project.DumbService; import com.intellij.openapi.project.Project; -import com.intellij.openapi.startup.StartupActivity; +import com.intellij.openapi.startup.ProjectActivity; +import kotlin.Unit; +import kotlin.coroutines.Continuation; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.xoops.support.settings.XoopsSettingsState; /** @@ -19,10 +22,15 @@ *

All deferred work is expired with {@link Project#getDisposed()} so pending callbacks * do not pin the plugin classloader across unload. */ -public final class XoopsStartupActivity implements StartupActivity { +public final class XoopsStartupActivity implements ProjectActivity { @Override - public void runActivity(@NotNull Project project) { + public @Nullable Object execute(@NotNull Project project, @NotNull Continuation continuation) { + scheduleNotification(project); + return Unit.INSTANCE; + } + + private void scheduleNotification(@NotNull Project project) { if (project.isDisposed()) { return; } diff --git a/src/main/java/org/xoops/support/actions/ShowXoopsProjectInfoAction.java b/src/main/java/org/xoops/support/actions/ShowXoopsProjectInfoAction.java index 37c15a0..ff359ee 100644 --- a/src/main/java/org/xoops/support/actions/ShowXoopsProjectInfoAction.java +++ b/src/main/java/org/xoops/support/actions/ShowXoopsProjectInfoAction.java @@ -62,7 +62,7 @@ public void run(@NotNull ProgressIndicator indicator) { if (!report.xoopsProject()) { sb.append("No XOOPS markers found (mainfile.php / xoops_version.php)."); } else { - sb.append(report.profile().displayName()).append('\n'); + sb.append(report.coreVersion().displayName()).append('\n'); sb.append("Web root: ").append(report.webRoot()).append('\n'); sb.append("Modules: ").append(report.modules().size()).append('\n'); int i = 0; diff --git a/src/main/java/org/xoops/support/completion/XoopsLanguageConstantParser.java b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantParser.java new file mode 100644 index 0000000..aee2d74 --- /dev/null +++ b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantParser.java @@ -0,0 +1,70 @@ +package org.xoops.support.completion; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Parses {@code define('_MI_…', …)} (and AM/MD/CO/MB) from a language PHP file. + * Pure regex so it can be unit-tested without PSI. + */ +public final class XoopsLanguageConstantParser { + + public static final Pattern DEFINE = Pattern.compile( + "define\\s*\\(\\s*['\"](_(?:MI|AM|MD|CO|MB)_[A-Z0-9_]+)['\"]", + Pattern.CASE_INSENSITIVE + ); + + public static final Pattern CONSTANT_NAME = Pattern.compile( + "_(?:MI|AM|MD|CO|MB)_[A-Z0-9_]+" + ); + + private XoopsLanguageConstantParser() { + } + + /** + * @return occurrences of {@code define('NAME'} as (name, offset-of-name) + */ + public static @NotNull List parse(@NotNull String phpSource) { + List out = new ArrayList<>(); + Matcher m = DEFINE.matcher(phpSource); + while (m.find()) { + out.add(new Occurrence(m.group(1), m.start(1))); + } + return out; + } + + /** + * Extract a XOOPS language-constant name from a PSI element's text + * (quoted string, bare identifier, or Smarty token). + */ + public static @Nullable String extractName(@NotNull String elementText) { + String trimmed = elementText.strip(); + if (trimmed.length() >= 2) { + char a = trimmed.charAt(0); + char b = trimmed.charAt(trimmed.length() - 1); + if ((a == '\'' && b == '\'') || (a == '"' && b == '"')) { + trimmed = trimmed.substring(1, trimmed.length() - 1); + } + } + String upper = trimmed.toUpperCase(Locale.ROOT); + return CONSTANT_NAME.matcher(upper).matches() ? upper : null; + } + + public static boolean isLanguagePath(@Nullable String path) { + if (path == null) { + return false; + } + String p = path.replace('\\', '/').toLowerCase(Locale.ROOT); + return p.contains("/language/") + && (p.endsWith(".php") || p.endsWith("/language") || !p.contains(".")); + } + + public record Occurrence(@NotNull String name, int offset) { + } +} diff --git a/src/main/java/org/xoops/support/completion/XoopsLanguageConstantReference.java b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantReference.java new file mode 100644 index 0000000..f9b99cb --- /dev/null +++ b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantReference.java @@ -0,0 +1,40 @@ +package org.xoops.support.completion; + +import com.intellij.openapi.util.TextRange; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiReferenceBase; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Resolves a {@code _MI_}/{@code _AM_}/{@code _MD_}/{@code _CO_}/{@code _MB_} name + * to its {@code define()} in a language PHP file. + */ +public final class XoopsLanguageConstantReference extends PsiReferenceBase { + + private final String name; + + public XoopsLanguageConstantReference(@NotNull PsiElement element, @NotNull String name) { + super(element, TextRange.from(0, element.getTextLength()), true); + this.name = name; + } + + public XoopsLanguageConstantReference( + @NotNull PsiElement element, + @NotNull TextRange rangeInElement, + @NotNull String name + ) { + super(element, rangeInElement, true); + this.name = name; + } + + @Override + public @Nullable PsiElement resolve() { + return XoopsLanguageConstantsCache.getInstance(getElement().getProject()).resolve(name); + } + + @Override + public @NotNull String getCanonicalText() { + return name; + } +} diff --git a/src/main/java/org/xoops/support/completion/XoopsLanguageConstantReferenceContributor.java b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantReferenceContributor.java new file mode 100644 index 0000000..5a04f02 --- /dev/null +++ b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantReferenceContributor.java @@ -0,0 +1,56 @@ +package org.xoops.support.completion; + +import com.intellij.patterns.PlatformPatterns; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiReference; +import com.intellij.psi.PsiReferenceContributor; +import com.intellij.psi.PsiReferenceProvider; +import com.intellij.psi.PsiReferenceRegistrar; +import com.intellij.util.ProcessingContext; +import com.jetbrains.php.lang.psi.elements.ConstantReference; +import com.jetbrains.php.lang.psi.elements.StringLiteralExpression; +import org.jetbrains.annotations.NotNull; +import org.xoops.support.XoopsSupportPlugin; + +/** + * Ctrl+B / Find Usages for XOOPS language constants ({@code _MD_FOO}, {@code '_MI_BAR'}). + */ +public final class XoopsLanguageConstantReferenceContributor extends PsiReferenceContributor { + + @Override + public void registerReferenceProviders(@NotNull PsiReferenceRegistrar registrar) { + PsiReferenceProvider provider = new PsiReferenceProvider() { + @Override + public PsiReference @NotNull [] getReferencesByElement( + @NotNull PsiElement element, + @NotNull ProcessingContext context + ) { + if (!XoopsSupportPlugin.isEnabled(element.getProject())) { + return PsiReference.EMPTY_ARRAY; + } + String name = nameOf(element); + if (name == null) { + return PsiReference.EMPTY_ARRAY; + } + return new PsiReference[]{new XoopsLanguageConstantReference(element, name)}; + } + }; + registrar.registerReferenceProvider(PlatformPatterns.psiElement(StringLiteralExpression.class), provider); + registrar.registerReferenceProvider(PlatformPatterns.psiElement(ConstantReference.class), provider); + } + + private static String nameOf(@NotNull PsiElement element) { + if (element instanceof StringLiteralExpression stringLiteral) { + return XoopsLanguageConstantParser.extractName(stringLiteral.getContents()); + } + if (element instanceof ConstantReference constantReference) { + String n = constantReference.getName(); + return n == null ? null : XoopsLanguageConstantParser.extractName(n); + } + // Smarty tokens and other leaves: only if the whole text is the constant. + if (element.getChildren().length == 0) { + return XoopsLanguageConstantParser.extractName(element.getText()); + } + return null; + } +} diff --git a/src/main/java/org/xoops/support/completion/XoopsLanguageConstantsCache.java b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantsCache.java index f3e2d0a..1db2c22 100644 --- a/src/main/java/org/xoops/support/completion/XoopsLanguageConstantsCache.java +++ b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantsCache.java @@ -10,6 +10,7 @@ import com.intellij.openapi.vfs.VirtualFile; import com.intellij.openapi.vfs.VirtualFileManager; import com.intellij.openapi.vfs.newvfs.events.VFileEvent; +import com.intellij.psi.PsiElement; import com.intellij.psi.PsiFile; import com.intellij.psi.PsiManager; import com.intellij.psi.search.FilenameIndex; @@ -22,44 +23,34 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; +import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Set; -import java.util.regex.Matcher; -import java.util.regex.Pattern; /** - * Project-level cache of XOOPS language constants from language/*.php define() lines. - * Tied to {@link PsiModificationTracker#MODIFICATION_COUNT} so unsaved PSI edits invalidate - * the cache; VFS listener also clears the soft project key on disk changes. - * - *

Implements {@link Disposable}: the VFS listener parent must be the service - * (not the project). Parenting to the project keeps the listener after plugin unload and - * pins the plugin classloader — "didn't unload fully". + * Project-level cache of XOOPS language constants from every PHP file under + * {@code language/}. Tied to {@link PsiModificationTracker#MODIFICATION_COUNT}; + * VFS listener parent is this service (not the project) so plugin unload disposes it. */ @Service(Service.Level.PROJECT) public final class XoopsLanguageConstantsCache implements Disposable { - private static final Key>> CACHE_KEY = + private static final Key> CACHE_KEY = Key.create("xoops.support.languageConstants"); - private static final Pattern DEFINE = Pattern.compile( - "define\\s*\\(\\s*['\"](_(?:MI|AM|MD|CO|MB)_[A-Z0-9_]+)['\"]", - Pattern.CASE_INSENSITIVE - ); - - private static final String[] LANGUAGE_FILES = { - "modinfo.php", "main.php", "admin.php", "blocks.php", "common.php" - }; + private static final int MAX_CONSTANTS = 5000; private final Project project; private volatile boolean disposed; public XoopsLanguageConstantsCache(@NotNull Project project) { this.project = project; - // Parent Disposable = this service (disposed on plugin unload), not the Project. VirtualFileManager.getInstance().addAsyncFileListener( events -> { if (disposed || project.isDisposed()) { @@ -68,12 +59,12 @@ public XoopsLanguageConstantsCache(@NotNull Project project) { boolean hit = false; for (VFileEvent event : events) { VirtualFile file = event.getFile(); - if (file != null && isLanguagePath(file.getPath())) { + if (file != null && XoopsLanguageConstantParser.isLanguagePath(file.getPath())) { hit = true; break; } String path = event.getPath(); - if (path != null && isLanguagePath(path)) { + if (path != null && XoopsLanguageConstantParser.isLanguagePath(path)) { hit = true; break; } @@ -105,76 +96,103 @@ public void invalidate() { } public @NotNull Set getConstants() { + Index index = getIndex(); + return index == null ? Collections.emptySet() : index.names; + } + + /** + * PSI element of the {@code define('_FOO_'} name in a language file (prefers {@code /english/}). + */ + public @Nullable PsiElement resolve(@NotNull String name) { + Index index = getIndex(); + if (index == null) { + return null; + } + List defs = index.defs.get(name); + if (defs == null || defs.isEmpty()) { + return null; + } + Def chosen = defs.get(0); + for (Def d : defs) { + String path = d.file.getPath().replace('\\', '/').toLowerCase(Locale.ROOT); + if (path.contains("/english/")) { + chosen = d; + break; + } + } + if (project.isDisposed()) { + return null; + } + PsiFile psi = PsiManager.getInstance(project).findFile(chosen.file); + if (psi == null) { + return null; + } + return psi.findElementAt(chosen.offset); + } + + @Override + public void dispose() { + disposed = true; + invalidate(); + } + + private @Nullable Index getIndex() { if (disposed || project.isDisposed()) { - return Collections.emptySet(); + return null; } try { return ReadAction.compute(() -> { if (disposed || project.isDisposed()) { - return Collections.emptySet(); + return null; } - CachedValue> cached = project.getUserData(CACHE_KEY); + CachedValue cached = project.getUserData(CACHE_KEY); if (cached == null) { cached = CachedValuesManager.getManager(project).createCachedValue( - () -> { - Set value = collectUnderReadLock(); - return CachedValueProvider.Result.create( - value, - PsiModificationTracker.MODIFICATION_COUNT - ); - }, + () -> CachedValueProvider.Result.create( + collectUnderReadLock(), + PsiModificationTracker.MODIFICATION_COUNT + ), false ); project.putUserData(CACHE_KEY, cached); } - Set result = cached.getValue(); - return result != null ? result : Collections.emptySet(); + return cached.getValue(); }); } catch (IndexNotReadyException e) { - return Collections.emptySet(); + return null; } } - @Override - public void dispose() { - disposed = true; - invalidate(); - } - @RequiresReadLock - private @NotNull Set collectUnderReadLock() { + private @NotNull Index collectUnderReadLock() { Set names = new LinkedHashSet<>(); + Map> defs = new LinkedHashMap<>(); PsiManager psiManager = PsiManager.getInstance(project); GlobalSearchScope scope = GlobalSearchScope.projectScope(project); - for (String fileName : LANGUAGE_FILES) { - Collection files = FilenameIndex.getVirtualFilesByName(fileName, scope); - for (VirtualFile vf : files) { - String path = vf.getPath().replace('\\', '/').toLowerCase(Locale.ROOT); - if (!path.contains("/language/") || path.contains("/vendor/")) { - continue; - } - PsiFile psi = psiManager.findFile(vf); - if (psi == null) { - continue; - } - Matcher m = DEFINE.matcher(psi.getText()); - while (m.find()) { - names.add(m.group(1)); - if (names.size() > 5000) { - return Collections.unmodifiableSet(names); - } + Collection files = FilenameIndex.getAllFilesByExt(project, "php", scope); + for (VirtualFile vf : files) { + if (!XoopsLanguageConstantParser.isLanguagePath(vf.getPath()) + || vf.getPath().replace('\\', '/').toLowerCase(Locale.ROOT).contains("/vendor/")) { + continue; + } + PsiFile psi = psiManager.findFile(vf); + if (psi == null) { + continue; + } + for (XoopsLanguageConstantParser.Occurrence occ : XoopsLanguageConstantParser.parse(psi.getText())) { + names.add(occ.name()); + defs.computeIfAbsent(occ.name(), k -> new ArrayList<>()).add(new Def(vf, occ.offset())); + if (names.size() > MAX_CONSTANTS) { + return new Index(Collections.unmodifiableSet(names), Map.copyOf(defs)); } } } - return Collections.unmodifiableSet(names); + return new Index(Collections.unmodifiableSet(names), Map.copyOf(defs)); } - private static boolean isLanguagePath(@Nullable String path) { - if (path == null) { - return false; - } - String p = path.replace('\\', '/').toLowerCase(Locale.ROOT); - return p.contains("/language/") - && (p.endsWith(".php") || p.endsWith("/language") || !p.contains(".")); + private record Def(@NotNull VirtualFile file, int offset) { + } + + private record Index(@NotNull Set names, @NotNull Map> defs) { } } diff --git a/src/main/java/org/xoops/support/inspections/InsertBeforeStatementQuickFix.java b/src/main/java/org/xoops/support/inspections/InsertBeforeStatementQuickFix.java new file mode 100644 index 0000000..647998b --- /dev/null +++ b/src/main/java/org/xoops/support/inspections/InsertBeforeStatementQuickFix.java @@ -0,0 +1,86 @@ +package org.xoops.support.inspections; + +import com.intellij.codeInspection.LocalQuickFix; +import com.intellij.codeInspection.ProblemDescriptor; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.project.Project; +import com.intellij.psi.PsiDocumentManager; +import com.intellij.psi.PsiElement; +import com.intellij.psi.util.PsiTreeUtil; +import com.jetbrains.php.lang.psi.elements.Statement; +import org.jetbrains.annotations.NotNull; + +/** + * Inserts an isResultSet throw-guard immediately before the enclosing statement of + * the problem element. Offset and indent are computed at apply-time so a batch + * Inspect Code run still works after an earlier fix in the same file shifted lines. + */ +public final class InsertBeforeStatementQuickFix implements LocalQuickFix { + + private static final String FAIL_ACTION = "throw new \\RuntimeException('Database query failed');"; + + private final String dbExpr; + private final String resultVar; + + public InsertBeforeStatementQuickFix(@NotNull String dbExpr, @NotNull String resultVar) { + this.dbExpr = dbExpr; + this.resultVar = resultVar; + } + + @Override + public @NotNull String getFamilyName() { + return "Insert isResultSet guard before fetch"; + } + + @Override + public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descriptor) { + PsiElement leaf = descriptor.getPsiElement(); + if (leaf == null) { + return; + } + Statement stmt = PsiTreeUtil.getParentOfType(leaf, Statement.class, false); + if (stmt == null) { + return; + } + Document document = DocumentEditHelper.documentOf(project, leaf); + if (document == null) { + return; + } + int insertAt = stmt.getTextRange().getStartOffset(); + if (insertAt < 0 || insertAt > document.getTextLength()) { + return; + } + String text = document.getText(); + if (alreadyGuardedAbove(text, insertAt, resultVar)) { + return; + } + String indent = guessIndent(text, insertAt); + String block = indent + "if (!" + dbExpr + "->isResultSet(" + resultVar + + ") || !" + resultVar + " instanceof \\mysqli_result) {\n" + + indent + " " + FAIL_ACTION + "\n" + + indent + "}\n"; + document.insertString(insertAt, block); + PsiDocumentManager.getInstance(project).commitDocument(document); + } + + /** + * True when the statement immediately above {@code insertAt} already throws/returns + * on {@code !isResultSet($var)}. Prevents a second insert if a stale duplicate finding + * is applied after the first fix in the same Inspect Code session. + */ + static boolean alreadyGuardedAbove(@NotNull String text, int insertAt, @NotNull String resultVar) { + int from = Math.max(0, insertAt - 400); + String preceding = text.substring(from, insertAt); + return preceding.contains("isResultSet(" + resultVar + ")"); + } + + private static String guessIndent(@NotNull String text, int offset) { + int start = text.lastIndexOf('\n', Math.max(0, offset - 1)); + start = start < 0 ? 0 : start + 1; + int i = start; + while (i < text.length() && (text.charAt(i) == ' ' || text.charAt(i) == '\t')) { + i++; + } + return text.substring(start, i); + } +} diff --git a/src/main/java/org/xoops/support/inspections/InsertRootPathGuardQuickFix.java b/src/main/java/org/xoops/support/inspections/InsertRootPathGuardQuickFix.java index 1fe4d2d..215a6a3 100644 --- a/src/main/java/org/xoops/support/inspections/InsertRootPathGuardQuickFix.java +++ b/src/main/java/org/xoops/support/inspections/InsertRootPathGuardQuickFix.java @@ -13,13 +13,12 @@ import java.util.regex.Pattern; /** - * Inserts a file-leading XOOPS_ROOT_PATH guard after a leading PHP open tag. - * Handles {@code = 0) { + insertGuardAt(document, project, policyOffset); return; } - // Leading short \n"); @@ -73,12 +69,10 @@ public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descri return; } - // No recognized leading open tag — only prepend if file has no PHP open at all if (!text.contains(" '" + templateName.replace("'", "\\'") + "',\n" + + " 'description' => '',\n" + + "];\n"; + int insertAt = text.length(); + int closePhp = text.lastIndexOf("?>"); + if (closePhp >= 0 && text.substring(closePhp).trim().equals("?>")) { + insertAt = closePhp; + } + document.insertString(insertAt, entry); + PsiDocumentManager.getInstance(project).commitDocument(document); + } +} diff --git a/src/main/java/org/xoops/support/inspections/ReplacePsiTextQuickFix.java b/src/main/java/org/xoops/support/inspections/ReplacePsiTextQuickFix.java new file mode 100644 index 0000000..545f12f --- /dev/null +++ b/src/main/java/org/xoops/support/inspections/ReplacePsiTextQuickFix.java @@ -0,0 +1,48 @@ +package org.xoops.support.inspections; + +import com.intellij.codeInspection.LocalQuickFix; +import com.intellij.codeInspection.ProblemDescriptor; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.TextRange; +import com.intellij.psi.PsiDocumentManager; +import com.intellij.psi.PsiElement; +import org.jetbrains.annotations.NotNull; + +/** + * Replaces the problem PSI element's current text. Safe across batch Inspect Code + * because the range is read from the element at apply-time, not a frozen offset. + */ +public final class ReplacePsiTextQuickFix implements LocalQuickFix { + + private final String familyName; + private final String replacement; + + public ReplacePsiTextQuickFix(@NotNull String familyName, @NotNull String replacement) { + this.familyName = familyName; + this.replacement = replacement; + } + + @Override + public @NotNull String getFamilyName() { + return familyName; + } + + @Override + public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descriptor) { + PsiElement element = descriptor.getPsiElement(); + if (element == null) { + return; + } + Document document = DocumentEditHelper.documentOf(project, element); + if (document == null) { + return; + } + TextRange range = element.getTextRange(); + if (range == null || range.getEndOffset() > document.getTextLength()) { + return; + } + document.replaceString(range.getStartOffset(), range.getEndOffset(), replacement); + PsiDocumentManager.getInstance(project).commitDocument(document); + } +} diff --git a/src/main/java/org/xoops/support/inspections/XoopsDeprecatedDbApiInspection.java b/src/main/java/org/xoops/support/inspections/XoopsDeprecatedDbApiInspection.java index 16e6173..22e1dae 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsDeprecatedDbApiInspection.java +++ b/src/main/java/org/xoops/support/inspections/XoopsDeprecatedDbApiInspection.java @@ -29,7 +29,7 @@ public final class XoopsDeprecatedDbApiInspection extends LocalInspectionTool { return new PsiElementVisitor() { @Override public void visitFile(@NotNull PsiFile file) { - if (!XoopsSupportPlugin.isEnabled(file)) { + if (!XoopsSupportPlugin.isEnabled(file) || !PhpTextUtil.isPrimaryPsiFile(file)) { return; } if (!PhpTextUtil.isPhpFile(file) || PhpTextUtil.looksLikeVendorOrCache(file)) { diff --git a/src/main/java/org/xoops/support/inspections/XoopsDeprecatedUnicodeDtypeInspection.java b/src/main/java/org/xoops/support/inspections/XoopsDeprecatedUnicodeDtypeInspection.java new file mode 100644 index 0000000..1952f5b --- /dev/null +++ b/src/main/java/org/xoops/support/inspections/XoopsDeprecatedUnicodeDtypeInspection.java @@ -0,0 +1,73 @@ +package org.xoops.support.inspections; + +import com.intellij.codeInspection.LocalInspectionTool; +import com.intellij.codeInspection.ProblemsHolder; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiElementVisitor; +import com.intellij.psi.PsiFile; +import org.jetbrains.annotations.NotNull; +import org.xoops.support.XoopsSupportPlugin; +import org.xoops.support.settings.XoopsSettingsState; + +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * {@code XOBJ_DTYPE_UNICODE_*} was deprecated in XOOPS 2.7.3 (#164) with removal + * scheduled for 4.0. Rename-only quick-fix; value migration stays a core concern. + * Silent when the project Core Version is explicitly 2.5. + */ +public final class XoopsDeprecatedUnicodeDtypeInspection extends LocalInspectionTool { + + static final Map SUCCESSOR = Map.of( + "XOBJ_DTYPE_UNICODE_TXTBOX", "XOBJ_DTYPE_TXTBOX", + "XOBJ_DTYPE_UNICODE_TXTAREA", "XOBJ_DTYPE_TXTAREA", + "XOBJ_DTYPE_UNICODE_URL", "XOBJ_DTYPE_URL", + "XOBJ_DTYPE_UNICODE_EMAIL", "XOBJ_DTYPE_EMAIL", + "XOBJ_DTYPE_UNICODE_ARRAY", "XOBJ_DTYPE_ARRAY", + "XOBJ_DTYPE_UNICODE_OTHER", "XOBJ_DTYPE_OTHER" + ); + + private static final Pattern UNICODE_DTYPE = Pattern.compile( + "\\b(XOBJ_DTYPE_UNICODE_(?:TXTBOX|TXTAREA|URL|EMAIL|ARRAY|OTHER))\\b" + ); + + @Override + public @NotNull PsiElementVisitor buildVisitor(@NotNull ProblemsHolder holder, boolean isOnTheFly) { + return new PsiElementVisitor() { + @Override + public void visitFile(@NotNull PsiFile file) { + if (!XoopsSupportPlugin.isEnabled(file) || !PhpTextUtil.isPrimaryPsiFile(file)) { + return; + } + if (!PhpTextUtil.isPhpFile(file) || PhpTextUtil.looksLikeVendorOrCache(file)) { + return; + } + String coreVersion = XoopsSettingsState.getInstance(file.getProject()).coreVersion; + if ("2.5".equals(coreVersion)) { + return; + } + String text = file.getText(); + String code = PhpTextUtil.maskCommentsAndStrings(text); + Matcher m = UNICODE_DTYPE.matcher(code); + while (m.find()) { + String old = m.group(1); + String next = SUCCESSOR.get(old); + if (next == null) { + continue; + } + PsiElement leaf = PhpTextUtil.leafAt(file, m.start(1)); + if (leaf == null) { + continue; + } + holder.registerProblem( + leaf, + "XOOPS: " + old + " is deprecated since 2.7.3; use " + next, + new ReplacePsiTextQuickFix("Replace with " + next, next) + ); + } + } + }; + } +} diff --git a/src/main/java/org/xoops/support/inspections/XoopsIncludeOnceHeaderInspection.java b/src/main/java/org/xoops/support/inspections/XoopsIncludeOnceHeaderInspection.java index 5dadffe..8ab2b09 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsIncludeOnceHeaderInspection.java +++ b/src/main/java/org/xoops/support/inspections/XoopsIncludeOnceHeaderInspection.java @@ -27,7 +27,7 @@ public final class XoopsIncludeOnceHeaderInspection extends LocalInspectionTool return new PsiElementVisitor() { @Override public void visitFile(@NotNull PsiFile file) { - if (!XoopsSupportPlugin.isEnabled(file)) { + if (!XoopsSupportPlugin.isEnabled(file) || !PhpTextUtil.isPrimaryPsiFile(file)) { return; } if (!PhpTextUtil.isPhpFile(file) || PhpTextUtil.looksLikeVendorOrCache(file)) { diff --git a/src/main/java/org/xoops/support/inspections/XoopsMissingRegisteredTemplateInspection.java b/src/main/java/org/xoops/support/inspections/XoopsMissingRegisteredTemplateInspection.java index b7042e9..a5aacdf 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsMissingRegisteredTemplateInspection.java +++ b/src/main/java/org/xoops/support/inspections/XoopsMissingRegisteredTemplateInspection.java @@ -26,7 +26,7 @@ public final class XoopsMissingRegisteredTemplateInspection extends LocalInspect return new PsiElementVisitor() { @Override public void visitFile(@NotNull PsiFile file) { - if (!XoopsSupportPlugin.isEnabled(file)) { + if (!XoopsSupportPlugin.isEnabled(file) || !PhpTextUtil.isPrimaryPsiFile(file)) { return; } if (!"xoops_version.php".equalsIgnoreCase(file.getName())) { diff --git a/src/main/java/org/xoops/support/inspections/XoopsQueryExecInspection.java b/src/main/java/org/xoops/support/inspections/XoopsQueryExecInspection.java index e400898..2861c27 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsQueryExecInspection.java +++ b/src/main/java/org/xoops/support/inspections/XoopsQueryExecInspection.java @@ -29,7 +29,7 @@ public final class XoopsQueryExecInspection extends LocalInspectionTool { return new PsiElementVisitor() { @Override public void visitFile(@NotNull PsiFile file) { - if (!XoopsSupportPlugin.isEnabled(file)) { + if (!XoopsSupportPlugin.isEnabled(file) || !PhpTextUtil.isPrimaryPsiFile(file)) { return; } if (!PhpTextUtil.isPhpFile(file) || PhpTextUtil.looksLikeVendorOrCache(file)) { diff --git a/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java b/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java index 9e9764a..d61a861 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java +++ b/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java @@ -69,14 +69,12 @@ public final class XoopsResultSetGuardInspection extends LocalInspectionTool { "(?is)^\\s*(return|throw|exit|die|break|continue)\\b[^;{]*;?\\s*$" ); - private static final String FAIL_ACTION = "throw new \\RuntimeException('Database query failed');"; - @Override public @NotNull PsiElementVisitor buildVisitor(@NotNull ProblemsHolder holder, boolean isOnTheFly) { return new PsiElementVisitor() { @Override public void visitFile(@NotNull PsiFile file) { - if (!XoopsSupportPlugin.isEnabled(file)) { + if (!XoopsSupportPlugin.isEnabled(file) || !PhpTextUtil.isPrimaryPsiFile(file)) { return; } if (!PhpTextUtil.isPhpFile(file) || PhpTextUtil.looksLikeVendorOrCache(file)) { @@ -101,29 +99,13 @@ public void visitFile(@NotNull PsiFile file) { "XOOPS: call isResultSet($result) (and prefer mysqli_result check) before fetch*"; Statement stmt = PsiTreeUtil.getParentOfType(leaf, Statement.class, false); if (stmt == null) { - // No safe statement boundary — report without auto-fix. holder.registerProblem(leaf, message); continue; } - int insertAt = stmt.getTextRange().getStartOffset(); - String indentGuess = guessIndent(text, insertAt); - String block = indentGuess + "if (!" + dbExpr + "->isResultSet(" + resultVar - + ") || !" + resultVar + " instanceof \\mysqli_result) {\n" - + indentGuess + " " + FAIL_ACTION + "\n" - + indentGuess + "}\n"; - String expectedAt = text.substring( - insertAt, - Math.min(text.length(), insertAt + Math.min(32, stmt.getTextLength())) - ); holder.registerProblem( leaf, message, - new InsertBeforeOffsetQuickFix( - "Insert isResultSet guard before fetch", - insertAt, - block, - expectedAt - ) + new InsertBeforeStatementQuickFix(dbExpr, resultVar) ); } } @@ -146,17 +128,90 @@ private static boolean isFetchAlreadyGuarded( return false; } - IfCond last = before.get(before.size() - 1); - // Early-exit: only when every fall-through path implies isResultSet($var). - // Requires pure-enough negation (no top-level &&) + single exit body. - if (last.ifEnd <= fetchOffset - && isOnlyWhitespace(code.substring(last.ifEnd, fetchOffset)) - && isSafeEarlyExitCondition(last.condition, resultVar) - && bodyIsSimpleEarlyExit(code, last)) { + if (isInsidePositiveIsResultSetGuard(code, fetchOffset, resultVar, before)) { return true; } - return isInsidePositiveIsResultSetGuard(code, fetchOffset, resultVar, before); + // Dominating early-exit: a proven !isResultSet($var) throw/return covers later + // fetch* of $var (including while/for conditions) until $var is reassigned, + // a nested function appears, or a '}' closes the enclosing block. + for (int i = before.size() - 1; i >= 0; i--) { + IfCond ic = before.get(i); + if (ic.ifEnd > fetchOffset) { + continue; + } + if (!isSafeEarlyExitCondition(ic.condition, resultVar) || !bodyIsSimpleEarlyExit(code, ic)) { + continue; + } + if (assignsResultVar(code, ic.ifEnd, fetchOffset, resultVar)) { + continue; + } + if (closesOutOfScope(code, ic.ifEnd, fetchOffset) || hasFunctionKeyword(code, ic.ifEnd, fetchOffset)) { + continue; + } + return true; + } + return false; + } + + /** + * Visible for tests: unguarded {@code fetch*} start offsets in {@code text}. + */ + static @NotNull List unguardedFetchOffsets(@NotNull String text) { + String code = PhpTextUtil.maskCommentsAndStrings(text); + List allIfs = findIfConditions(code); + List out = new ArrayList<>(); + Matcher m = FETCH.matcher(code); + while (m.find()) { + String resultVar = m.group(3); + if (!isFetchAlreadyGuarded(code, m.start(), resultVar, allIfs)) { + out.add(m.start()); + } + } + return out; + } + + private static boolean assignsResultVar( + @NotNull String code, + int from, + int to, + @NotNull String resultVar + ) { + Pattern assign = Pattern.compile(Pattern.quote(resultVar) + "(?![\\w])\\s*=(?![=|>])"); + Matcher m = assign.matcher(code); + while (m.find()) { + if (m.start() >= from && m.start() < to) { + return true; + } + } + return false; + } + + private static boolean closesOutOfScope(@NotNull String code, int from, int to) { + int depth = 0; + int end = Math.min(to, code.length()); + for (int i = from; i < end; i++) { + char c = code.charAt(i); + if (c == '{') { + depth++; + } else if (c == '}') { + depth--; + if (depth < 0) { + return true; + } + } + } + return false; + } + + private static boolean hasFunctionKeyword(@NotNull String code, int from, int to) { + Matcher m = Pattern.compile("\\bfunction\\b", Pattern.CASE_INSENSITIVE).matcher(code); + while (m.find()) { + if (m.start() >= from && m.start() < to) { + return true; + } + } + return false; } private static @NotNull List findIfConditions(@NotNull String text) { @@ -228,15 +283,6 @@ private static int matchingCloseBrace(@NotNull String text, int openBrace) { return -1; } - private static boolean isOnlyWhitespace(@NotNull String s) { - for (int i = 0; i < s.length(); i++) { - if (!Character.isWhitespace(s.charAt(i))) { - return false; - } - } - return true; - } - /** * Only a single top-level exit statement counts (no nested if/blocks). * Avoids treating {@code if (!isResultSet($r)) { if ($x) return; }} as safe. @@ -402,20 +448,6 @@ private static boolean isInsidePositiveIsResultSetGuard( return false; } - private static String guessIndent(String text, int offset) { - int start = lineStart(text, offset); - int i = start; - while (i < text.length() && (text.charAt(i) == ' ' || text.charAt(i) == '\t')) { - i++; - } - return text.substring(start, i); - } - - private static int lineStart(String text, int offset) { - int i = text.lastIndexOf('\n', Math.max(0, offset - 1)); - return i < 0 ? 0 : i + 1; - } - private record IfCond( int ifStart, int openParen, diff --git a/src/main/java/org/xoops/support/inspections/XoopsRootPathGuardInspection.java b/src/main/java/org/xoops/support/inspections/XoopsRootPathGuardInspection.java index eddb288..733483e 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsRootPathGuardInspection.java +++ b/src/main/java/org/xoops/support/inspections/XoopsRootPathGuardInspection.java @@ -8,63 +8,40 @@ import org.jetbrains.annotations.NotNull; import org.xoops.support.XoopsSupportPlugin; -import java.util.Locale; import java.util.regex.Matcher; import java.util.regex.Pattern; /** - * Flags module/class PHP files that lack a terminating direct-access guard - * as the first executable statement after the opening PHP tag. + * Flags include-only PHP files that lack a terminating direct-access guard + * as the first executable statement after {@code Entry points (admin pages, mainfile includes) and directory-protection 404 stubs + * are skipped — see {@link XoopsRootPathGuardPolicy}. */ public final class XoopsRootPathGuardInspection extends LocalInspectionTool { private static final Pattern OPEN_PHP = Pattern.compile("<\\?php\\b", Pattern.CASE_INSENSITIVE); private static final Pattern OPEN_ANY = Pattern.compile("<\\?(?:php|=)?", Pattern.CASE_INSENSITIVE); - /** defined('XOOPS_ROOT_PATH') || exit/die(...); */ - private static final Pattern GUARD_OR = Pattern.compile( - "(?is)^\\s*defined\\s*\\(\\s*['\"]XOOPS_ROOT_PATH['\"]\\s*\\)\\s*\\|\\|\\s*(?:exit|die)\\s*\\s*(?:\\([^;]*\\))?\\s*;" - ); - - /** if (!defined('XOOPS_ROOT_PATH')) { exit/die(...); } */ - private static final Pattern GUARD_IF = Pattern.compile( - "(?is)^\\s*if\\s*\\(\\s*!\\s*defined\\s*\\(\\s*['\"]XOOPS_ROOT_PATH['\"]\\s*\\)\\s*\\)\\s*\\{" - + "\\s*(?:exit|die)\\s*(?:\\([^;]*\\))?\\s*;\\s*\\}" - ); - @Override public @NotNull PsiElementVisitor buildVisitor(@NotNull ProblemsHolder holder, boolean isOnTheFly) { return new PsiElementVisitor() { @Override public void visitFile(@NotNull PsiFile file) { - if (!XoopsSupportPlugin.isEnabled(file)) { + if (!XoopsSupportPlugin.isEnabled(file) || !PhpTextUtil.isPrimaryPsiFile(file)) { return; } if (!PhpTextUtil.isPhpFile(file) || PhpTextUtil.looksLikeVendorOrCache(file)) { return; } - if (PhpTextUtil.looksLikeLanguageFile(file)) { - return; - } String path = file.getVirtualFile() != null - ? file.getVirtualFile().getPath().replace('\\', '/').toLowerCase(Locale.ROOT) - : ""; - boolean inModuleOrClass = path.contains("/modules/") - || path.contains("/class/") - || path.contains("/preloads/") - || path.contains("/kernel/"); - if (!inModuleOrClass) { - return; - } + ? file.getVirtualFile().getPath() + : file.getName(); String text = file.getText(); if (text == null || text.isBlank()) { return; } - if (!text.contains("= masked.length()) { + return ""; + } + String rest = masked.substring(pos).stripLeading(); + return rest.replaceFirst("(?s)\\?>\\s*$", "").strip(); + } + + private static int skipWs(@NotNull String s, int pos) { + while (pos < s.length() && Character.isWhitespace(s.charAt(pos))) { + pos++; + } + return pos; + } + + private static int skipPattern(@NotNull String s, int pos, @NotNull Pattern pattern) { + if (pos >= s.length()) { + return pos; + } + Matcher m = pattern.matcher(s.substring(pos)); + if (m.find() && m.start() == 0) { + return pos + m.end(); + } + return pos; + } +} diff --git a/src/main/java/org/xoops/support/inspections/XoopsSuperglobalInspection.java b/src/main/java/org/xoops/support/inspections/XoopsSuperglobalInspection.java index 380e663..5f81067 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsSuperglobalInspection.java +++ b/src/main/java/org/xoops/support/inspections/XoopsSuperglobalInspection.java @@ -29,7 +29,7 @@ public final class XoopsSuperglobalInspection extends LocalInspectionTool { return new PsiElementVisitor() { @Override public void visitFile(@NotNull PsiFile file) { - if (!XoopsSupportPlugin.isEnabled(file)) { + if (!XoopsSupportPlugin.isEnabled(file) || !PhpTextUtil.isPrimaryPsiFile(file)) { return; } if (!PhpTextUtil.isPhpFile(file) || PhpTextUtil.looksLikeVendorOrCache(file)) { diff --git a/src/main/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspection.java b/src/main/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspection.java new file mode 100644 index 0000000..bd67289 --- /dev/null +++ b/src/main/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspection.java @@ -0,0 +1,113 @@ +package org.xoops.support.inspections; + +import com.intellij.codeInspection.LocalInspectionTool; +import com.intellij.codeInspection.ProblemsHolder; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiElementVisitor; +import com.intellij.psi.PsiFile; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.xoops.support.XoopsSupportPlugin; + +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Flags a module {@code .tpl} that sits under {@code templates/} (or {@code blocks/}) + * but is never listed in {@code xoops_version.php} — the inverse of + * {@link XoopsMissingRegisteredTemplateInspection}. + */ +public final class XoopsUnregisteredTemplateInspection extends LocalInspectionTool { + + private static final Pattern REGISTERED_TEMPLATE = Pattern.compile( + "(?is)['\"](?:file|template)['\"]\\s*=>\\s*['\"]([^'\"]+\\.tpl)['\"]" + ); + + @Override + public @NotNull PsiElementVisitor buildVisitor(@NotNull ProblemsHolder holder, boolean isOnTheFly) { + return new PsiElementVisitor() { + @Override + public void visitFile(@NotNull PsiFile file) { + if (!XoopsSupportPlugin.isEnabled(file) || !PhpTextUtil.isPrimaryPsiFile(file)) { + return; + } + String name = file.getName().toLowerCase(Locale.ROOT); + if (!name.endsWith(".tpl") || PhpTextUtil.looksLikeVendorOrCache(file)) { + return; + } + VirtualFile vf = file.getVirtualFile(); + if (vf == null) { + return; + } + String path = vf.getPath().replace('\\', '/').toLowerCase(Locale.ROOT); + if (path.contains("/themes/") || path.contains("/templates_c/")) { + return; + } + String relative = relativeTemplateName(vf); + if (relative == null) { + return; + } + VirtualFile moduleRoot = moduleRootOf(vf); + if (moduleRoot == null) { + return; + } + VirtualFile manifest = moduleRoot.findChild("xoops_version.php"); + if (manifest == null) { + return; + } + PsiFile manifestPsi = file.getManager().findFile(manifest); + if (manifestPsi == null) { + return; + } + Set registered = registeredTemplates(manifestPsi.getText()); + if (registered.contains(relative.toLowerCase(Locale.ROOT))) { + return; + } + PsiElement anchor = file.getFirstChild() != null ? file.getFirstChild() : file; + holder.registerProblem( + anchor, + "XOOPS: template is not registered in xoops_version.php: " + relative, + new RegisterTemplateQuickFix(relative) + ); + } + }; + } + + static @NotNull Set registeredTemplates(@NotNull String manifestText) { + Set out = new LinkedHashSet<>(); + Matcher m = REGISTERED_TEMPLATE.matcher(PhpTextUtil.maskCommentsOnly(manifestText)); + while (m.find()) { + out.add(m.group(1).replace('\\', '/').toLowerCase(Locale.ROOT)); + } + return out; + } + + private static @Nullable String relativeTemplateName(@NotNull VirtualFile tpl) { + VirtualFile dir = tpl.getParent(); + StringBuilder tail = new StringBuilder(tpl.getName()); + while (dir != null) { + String dirName = dir.getName().toLowerCase(Locale.ROOT); + if ("templates".equals(dirName) || "blocks".equals(dirName)) { + return tail.toString().replace('\\', '/'); + } + tail.insert(0, dir.getName() + "/"); + dir = dir.getParent(); + } + return null; + } + + private static @Nullable VirtualFile moduleRootOf(@NotNull VirtualFile tpl) { + VirtualFile dir = tpl.getParent(); + while (dir != null) { + if (dir.findChild("xoops_version.php") != null) { + return dir; + } + dir = dir.getParent(); + } + return null; + } +} diff --git a/src/main/java/org/xoops/support/inspections/XoopsWrongSmartyDelimiterInspection.java b/src/main/java/org/xoops/support/inspections/XoopsWrongSmartyDelimiterInspection.java index 89764e2..bd1dfb4 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsWrongSmartyDelimiterInspection.java +++ b/src/main/java/org/xoops/support/inspections/XoopsWrongSmartyDelimiterInspection.java @@ -25,7 +25,7 @@ public final class XoopsWrongSmartyDelimiterInspection extends LocalInspectionTo return new PsiElementVisitor() { @Override public void visitFile(@NotNull PsiFile file) { - if (!XoopsSupportPlugin.isEnabled(file)) { + if (!XoopsSupportPlugin.isEnabled(file) || !PhpTextUtil.isPrimaryPsiFile(file)) { return; } String name = file.getName().toLowerCase(java.util.Locale.ROOT); diff --git a/src/main/java/org/xoops/support/scanner/CoreProfile.java b/src/main/java/org/xoops/support/scanner/CoreVersion.java similarity index 81% rename from src/main/java/org/xoops/support/scanner/CoreProfile.java rename to src/main/java/org/xoops/support/scanner/CoreVersion.java index fb0ddc0..087f373 100644 --- a/src/main/java/org/xoops/support/scanner/CoreProfile.java +++ b/src/main/java/org/xoops/support/scanner/CoreVersion.java @@ -1,9 +1,9 @@ package org.xoops.support.scanner; /** - * Detected XOOPS core line . + * Detected XOOPS core line. */ -public enum CoreProfile { +public enum CoreVersion { XOOPS_25("XOOPS 2.5.x"), XOOPS_27("XOOPS 2.7.x"), XOOPS_40("XOOPS 4.0"), @@ -13,7 +13,7 @@ public enum CoreProfile { private final String displayName; - CoreProfile(String displayName) { + CoreVersion(String displayName) { this.displayName = displayName; } diff --git a/src/main/java/org/xoops/support/scanner/XoopsProjectReport.java b/src/main/java/org/xoops/support/scanner/XoopsProjectReport.java index 2b668af..aed04f6 100644 --- a/src/main/java/org/xoops/support/scanner/XoopsProjectReport.java +++ b/src/main/java/org/xoops/support/scanner/XoopsProjectReport.java @@ -7,7 +7,7 @@ public record XoopsProjectReport( boolean xoopsProject, Path projectRoot, Path webRoot, - CoreProfile profile, + CoreVersion coreVersion, List modules, List findings ) { diff --git a/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java b/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java index 73e8da4..6ef394c 100644 --- a/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java +++ b/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java @@ -8,6 +8,7 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.Comparator; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Optional; @@ -34,23 +35,23 @@ public final class XoopsProjectScanner { "smarty_compile", "templates_c", "uploads", "vendor", "xoops_data" ); - private static final Pattern VERSION_25 = Pattern.compile("(?i)(?:XOOPS[ _-]?)?2\\.5(?:\\.|[^0-9]|$)"); - private static final Pattern VERSION_27 = Pattern.compile("(?i)(?:XOOPS[ _-]?)?2\\.7(?:\\.|[^0-9]|$)"); - private static final Pattern VERSION_40 = Pattern.compile("(?i)(?:XOOPS[ _-]?)?4\\.0(?:\\.|[^0-9]|$)"); - private static final Pattern REGISTERED_TEMPLATE = Pattern.compile( + static final Pattern VERSION_25 = Pattern.compile("(?i)(?:XOOPS[ _-]?)?2\\.5(?:\\.|[^0-9]|$)"); + static final Pattern VERSION_27 = Pattern.compile("(?i)(?:XOOPS[ _-]?)?2\\.7(?:\\.|[^0-9]|$)"); + static final Pattern VERSION_40 = Pattern.compile("(?i)(?:XOOPS[ _-]?)?4\\.0(?:\\.|[^0-9]|$)"); + static final Pattern REGISTERED_TEMPLATE = Pattern.compile( "(?is)['\"](?:file|template)['\"]\\s*=>\\s*['\"]([^'\"]+\\.tpl)['\"]" ); private static final Pattern MANIFEST_DIRNAME = Pattern.compile( "(?is)(?:\\[['\"]dirname['\"]]\\s*=|['\"]dirname['\"]\\s*=>)\\s*['\"]([a-z0-9_-]+)['\"]" ); - private static final Pattern RAW_REQUEST = Pattern.compile("\\$_REQUEST\\b"); - private static final Pattern QUERY_F = Pattern.compile("->\\s*queryF\\s*\\("); - private static final Pattern QUOTE_STRING = Pattern.compile("->\\s*quoteString\\s*\\("); - private static final Pattern MUTATING_QUERY = Pattern.compile( + static final Pattern RAW_REQUEST = Pattern.compile("\\$_REQUEST\\b"); + static final Pattern QUERY_F = Pattern.compile("->\\s*queryF\\s*\\("); + static final Pattern QUOTE_STRING = Pattern.compile("->\\s*quoteString\\s*\\("); + static final Pattern MUTATING_QUERY = Pattern.compile( "(?is)->\\s*query\\s*\\(\\s*['\"]\\s*(?:INSERT|UPDATE|DELETE|REPLACE|ALTER|CREATE|DROP|TRUNCATE)\\b" ); - private static final Pattern WRONG_SMARTY = Pattern.compile( + static final Pattern WRONG_SMARTY = Pattern.compile( "(?i)(? f.path().toString(), String.CASE_INSENSITIVE_ORDER) .thenComparingInt(XoopsFinding::line)); - CoreProfile profile = standaloneModule && !isCoreRoot(webRoot) - ? CoreProfile.MODULE_ONLY - : detectProfile(projectRoot, webRoot); + CoreVersion coreVersion = standaloneModule && !isCoreRoot(webRoot) + ? CoreVersion.MODULE_ONLY + : detectCoreVersion(projectRoot, webRoot); - return new XoopsProjectReport(true, projectRoot, webRoot, profile, modules, findings); + return new XoopsProjectReport(true, projectRoot, webRoot, coreVersion, modules, findings); } private static Path detectWebRoot(Path projectRoot) { @@ -173,7 +174,7 @@ private static void checkCanceledEvery(int[] pathCounter) { } } - private static CoreProfile detectProfile(Path projectRoot, Path webRoot) { + private static CoreVersion detectCoreVersion(Path projectRoot, Path webRoot) { // Prefer core include/version.php — composer.json dependency ranges often mislead (e.g. "2.5"). for (Path candidate : List.of( webRoot.resolve("include/version.php"), @@ -185,13 +186,13 @@ private static CoreProfile detectProfile(Path projectRoot, Path webRoot) { } String text = body.get(); if (VERSION_40.matcher(text).find()) { - return CoreProfile.XOOPS_40; + return CoreVersion.XOOPS_40; } if (VERSION_27.matcher(text).find()) { - return CoreProfile.XOOPS_27; + return CoreVersion.XOOPS_27; } if (VERSION_25.matcher(text).find()) { - return CoreProfile.XOOPS_25; + return CoreVersion.XOOPS_25; } } // Fallback: bind package name to its version constraint (not independent whole-file matches). @@ -200,19 +201,19 @@ private static CoreProfile detectProfile(Path projectRoot, Path webRoot) { if (body.isEmpty()) { continue; } - CoreProfile fromComposer = profileFromComposerJson(body.get()); - if (fromComposer != CoreProfile.UNKNOWN) { + CoreVersion fromComposer = coreVersionFromComposerJson(body.get()); + if (fromComposer != CoreVersion.UNKNOWN) { return fromComposer; } } - return CoreProfile.UNKNOWN; + return CoreVersion.UNKNOWN; } /** * Match a single Composer require entry whose package name contains "xoops" * and apply version patterns only to that entry's constraint. */ - private static CoreProfile profileFromComposerJson(@NotNull String json) { + private static CoreVersion coreVersionFromComposerJson(@NotNull String json) { // "xoops/something": "2.5.11" or "xoopsmodules/foo": "^2.7" Pattern entry = Pattern.compile( "(?is)\"([^\"]*xoops[^\"]*)\"\\s*:\\s*\"([^\"]+)\"" @@ -226,16 +227,16 @@ private static CoreProfile profileFromComposerJson(@NotNull String json) { continue; } if (VERSION_40.matcher(constraint).find()) { - return CoreProfile.XOOPS_40; + return CoreVersion.XOOPS_40; } if (VERSION_27.matcher(constraint).find()) { - return CoreProfile.XOOPS_27; + return CoreVersion.XOOPS_27; } if (VERSION_25.matcher(constraint).find()) { - return CoreProfile.XOOPS_25; + return CoreVersion.XOOPS_25; } } - return CoreProfile.UNKNOWN; + return CoreVersion.UNKNOWN; } private void scanModule(Path moduleRoot, List findings) { @@ -308,26 +309,56 @@ private static void addFirst( } } - private static void checkRegisteredTemplates(Path moduleRoot, List findings) { + static void checkRegisteredTemplates(Path moduleRoot, List findings) { Path manifest = moduleRoot.resolve("xoops_version.php"); - String content = readSmallFile(manifest).orElse(null); - if (content == null) { + String content = readSmallFile(manifest).orElse(""); + Set registered = new LinkedHashSet<>(); + if (!content.isEmpty()) { + Matcher matcher = REGISTERED_TEMPLATE.matcher(content); + while (matcher.find()) { + String template = matcher.group(1).replace('\\', '/'); + registered.add(template.toLowerCase(Locale.ROOT)); + boolean exists = Files.isRegularFile(moduleRoot.resolve("templates").resolve(template)) + || Files.isRegularFile(moduleRoot.resolve("blocks").resolve(template)) + || Files.isRegularFile(moduleRoot.resolve(template)); + if (!exists) { + findings.add(new XoopsFinding( + "MISSING_REGISTERED_TEMPLATE", + manifest, + lineAt(content, matcher.start(1)), + "Registered template is missing: " + template + )); + } + } + } + addUnregisteredTemplates(moduleRoot.resolve("templates"), registered, findings); + addUnregisteredTemplates(moduleRoot.resolve("blocks"), registered, findings); + } + + private static void addUnregisteredTemplates( + Path directory, + Set registered, + List findings + ) { + if (!Files.isDirectory(directory)) { return; } - Matcher matcher = REGISTERED_TEMPLATE.matcher(content); - while (matcher.find()) { - String template = matcher.group(1).replace('\\', '/'); - boolean exists = Files.isRegularFile(moduleRoot.resolve("templates").resolve(template)) - || Files.isRegularFile(moduleRoot.resolve("blocks").resolve(template)) - || Files.isRegularFile(moduleRoot.resolve(template)); - if (!exists) { - findings.add(new XoopsFinding( - "MISSING_REGISTERED_TEMPLATE", - manifest, - lineAt(content, matcher.start(1)), - "Registered template is missing: " + template - )); - } + try (Stream paths = Files.walk(directory, 6)) { + paths.filter(Files::isRegularFile) + .filter(p -> p.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".tpl")) + .forEach(path -> { + String relative = directory.relativize(path).toString().replace('\\', '/'); + if (!registered.contains(relative.toLowerCase(Locale.ROOT))) { + findings.add(new XoopsFinding( + "UNREGISTERED_TEMPLATE", + path, + 1, + "Template is not registered in xoops_version.php: " + relative + )); + } + }); + } catch (IOException ignored) { + // skip unreadable template trees } } diff --git a/src/main/java/org/xoops/support/settings/XoopsConfigurable.java b/src/main/java/org/xoops/support/settings/XoopsConfigurable.java index c478a97..6ac8ba5 100644 --- a/src/main/java/org/xoops/support/settings/XoopsConfigurable.java +++ b/src/main/java/org/xoops/support/settings/XoopsConfigurable.java @@ -24,7 +24,7 @@ public final class XoopsConfigurable implements Configurable { private JCheckBox enabledBox; private JCheckBox suppressNotifyBox; private JCheckBox autoScanBox; - private JComboBox profileBox; + private JComboBox coreVersionBox; private JTextField prefixField; private JPanel panel; @@ -61,10 +61,10 @@ public XoopsConfigurable(Project project) { form.add(autoScanBox, c); c.gridy++; - form.add(new JLabel("Core profile:"), c); + form.add(new JLabel("Core Version:"), c); c.gridx = 1; - profileBox = new JComboBox<>(new String[]{"Auto", "2.5", "2.7", "4.0"}); - form.add(profileBox, c); + coreVersionBox = new JComboBox<>(new String[]{"Auto", "2.5", "2.7", "4.0"}); + form.add(coreVersionBox, c); c.gridx = 0; c.gridy++; @@ -82,13 +82,13 @@ public XoopsConfigurable(Project project) { @Override public boolean isModified() { XoopsSettingsState s = XoopsSettingsState.getInstance(project); - String selectedProfile = String.valueOf(profileBox.getSelectedItem()); - String storedProfile = s.coreProfile == null ? "Auto" : s.coreProfile; + String selectedCoreVersion = String.valueOf(coreVersionBox.getSelectedItem()); + String storedCoreVersion = s.coreVersion == null ? "Auto" : s.coreVersion; String storedPrefix = s.tablePrefix == null ? "" : s.tablePrefix; return enabledBox.isSelected() != s.enabled || suppressNotifyBox.isSelected() != s.suppressStartupNotification || autoScanBox.isSelected() != s.autoScanOnToolWindowOpen - || !Objects.equals(selectedProfile, storedProfile) + || !Objects.equals(selectedCoreVersion, storedCoreVersion) || !Objects.equals(prefixField.getText().trim(), storedPrefix); } @@ -98,7 +98,7 @@ public void apply() { s.enabled = enabledBox.isSelected(); s.suppressStartupNotification = suppressNotifyBox.isSelected(); s.autoScanOnToolWindowOpen = autoScanBox.isSelected(); - s.coreProfile = String.valueOf(profileBox.getSelectedItem()); + s.coreVersion = String.valueOf(coreVersionBox.getSelectedItem()); s.tablePrefix = prefixField.getText().trim(); } @@ -108,7 +108,7 @@ public void reset() { enabledBox.setSelected(s.enabled); suppressNotifyBox.setSelected(s.suppressStartupNotification); autoScanBox.setSelected(s.autoScanOnToolWindowOpen); - profileBox.setSelectedItem(s.coreProfile == null ? "Auto" : s.coreProfile); + coreVersionBox.setSelectedItem(s.coreVersion == null ? "Auto" : s.coreVersion); prefixField.setText(s.tablePrefix == null ? "" : s.tablePrefix); } } diff --git a/src/main/java/org/xoops/support/settings/XoopsSettingsState.java b/src/main/java/org/xoops/support/settings/XoopsSettingsState.java index 83e4d83..f1b3d55 100644 --- a/src/main/java/org/xoops/support/settings/XoopsSettingsState.java +++ b/src/main/java/org/xoops/support/settings/XoopsSettingsState.java @@ -22,7 +22,7 @@ public final class XoopsSettingsState implements PersistentStateComponent").append(escape(report.profile().displayName())).append("") + .append("

").append(escape(report.coreVersion().displayName())).append("

") .append("

Web root: ") .append(escape(report.webRoot().toString())) .append("

") diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 652d71d..a6b0b24 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -3,23 +3,32 @@ org.xoops.plugin.support XOOPS Support - 1.0.0-alpha.2 + XOOPS Project XOOPS Support — PhpStorm / IntelliJ helper for XOOPS 2.5 / 2.7 / 4.0 module and core work.

-

1.0.0 Alpha 2 — on-demand overview scan; inspections under top-level XOOPS group.

+

XOOPS Support — PhpStorm / IntelliJ helper for XOOPS 2.5 / 2.7 / 4.0 Core and module development.

+

1.0.0 Alpha 3 — Goffy field-report fixes; language-constant navigation; unregistered templates; UNICODE dtype.

  • Project detection, scanner, and HTML overview tool window
  • Inspections with Alt+Enter quick fixes (guards, query/exec, Request, Smarty, templates)
  • -
  • Live templates, language-constant completion, hybrid module scaffold
  • +
  • Live templates, language-constant completion and Ctrl+B, hybrid module scaffold
  • Dynamic install/update (no IDE restart when unload succeeds)
]]>
1.0.0 Alpha 3 (1.0.0-alpha.3) +
    +
  • Goffy / wgSimpleAcc: inspections no longer report every finding twice (PHP+HTML PSI).
  • +
  • isResultSet quick-fix uses the current PSI statement (Inspect Code batch apply works); early-exit guards cover while (list = fetchRow).
  • +
  • ROOT_PATH guard: recognize die after namespace/use; insert after namespace; skip 404 stubs, admin entry points, and mainfile/header bootstraps.
  • +
  • Language constants: scan every language/**/*.php; Ctrl+B / Find Usages on _MD_* (and MI/AM/CO/MB).
  • +
  • Unregistered .tpl inspection (inverse of missing registered template).
  • +
  • XOBJ_DTYPE_UNICODE_* deprecation with rename quick-fix (off when Core Version is 2.5).
  • +

1.0.0 Alpha 2 (1.0.0-alpha.2)

  • Performance: Overview no longer auto-scans the whole module tree when the tool window opens (monorepo boot freeze). Click Refresh to scan. Optional setting re-enables auto-scan.
  • @@ -37,6 +46,9 @@
]]>
+ com.intellij.modules.platform com.jetbrains.php @@ -131,6 +143,23 @@ displayName="Wrong Smarty delimiter (use XOOPS <{ }>)" shortName="XoopsWrongSmartyDelimiter"/> + + + + @@ -139,6 +168,13 @@ + + + diff --git a/src/main/resources/META-INF/pluginIcon.svg b/src/main/resources/META-INF/pluginIcon.svg index f2f5445..5c890b1 100644 --- a/src/main/resources/META-INF/pluginIcon.svg +++ b/src/main/resources/META-INF/pluginIcon.svg @@ -1,4 +1,34 @@ - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/toolWindowXoops.svg b/src/main/resources/icons/toolWindowXoops.svg index 20a2eff..ee55126 100644 --- a/src/main/resources/icons/toolWindowXoops.svg +++ b/src/main/resources/icons/toolWindowXoops.svg @@ -1,4 +1,34 @@ - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/inspectionDescriptions/XoopsDeprecatedUnicodeDtype.html b/src/main/resources/inspectionDescriptions/XoopsDeprecatedUnicodeDtype.html new file mode 100644 index 0000000..09a11b3 --- /dev/null +++ b/src/main/resources/inspectionDescriptions/XoopsDeprecatedUnicodeDtype.html @@ -0,0 +1,14 @@ + + + +XOBJ_DTYPE_UNICODE_* constants were deprecated in XOOPS 2.7.3 +(core issue #164) and are scheduled for removal in 4.0. +

+Quick-fix renames the constant to its non-UNICODE successor +(XOBJ_DTYPE_TXTBOX, TXTAREA, URL, +EMAIL, ARRAY, OTHER). +Value migration stays a core concern and is not performed here. +

+

Silent when the project Core Version is set to 2.5.

+ + diff --git a/src/main/resources/inspectionDescriptions/XoopsResultSetGuard.html b/src/main/resources/inspectionDescriptions/XoopsResultSetGuard.html index 5ec96ce..dc50fac 100644 --- a/src/main/resources/inspectionDescriptions/XoopsResultSetGuard.html +++ b/src/main/resources/inspectionDescriptions/XoopsResultSetGuard.html @@ -1,8 +1,16 @@ -Warns when fetchArray / fetchRow / fetchBoth appears without a nearby -isResultSet check. Prefer the two-part guard: -
if (!$db->isResultSet($result) || !$result instanceof \mysqli_result) { … }
+Warns when fetchArray / fetchRow / fetchBoth appears without a +dominating isResultSet check. Prefer the two-part guard: +
if (!$db->isResultSet($result) || !$result instanceof \mysqli_result) {
+    throw new \RuntimeException('Database query failed');
+}
+

+An early-exit guard covers later fetches of the same variable, including +while (list(...) = $db->fetchRow($result)), until that variable is reassigned. +The quick-fix inserts before the enclosing statement using the current PSI +(safe to apply several times after Inspect Code). +

diff --git a/src/main/resources/inspectionDescriptions/XoopsRootPathGuard.html b/src/main/resources/inspectionDescriptions/XoopsRootPathGuard.html index 5be037f..805ae40 100644 --- a/src/main/resources/inspectionDescriptions/XoopsRootPathGuard.html +++ b/src/main/resources/inspectionDescriptions/XoopsRootPathGuard.html @@ -1,8 +1,20 @@ -Reports PHP files under modules/, class/, preloads/, or kernel/ -that do not contain a defined('XOOPS_ROOT_PATH') direct-access guard. -

Quick-fix inserts the standard guard after <?php.

+Reports include-only PHP files (classes, preloads, includes, blocks) that lack a +terminating defined('XOOPS_ROOT_PATH') || exit/die(...) guard. +

+The guard must be the first executable statement after <?php, +declare, namespace, and use. +die and exit are both accepted. +In namespaced files the quick-fix inserts the guard after the namespace +(never before — that is invalid PHP). +

+

+Not reported: language files, vendor/cache, tests, xoops_version.php, +admin/ control-panel scripts, files whose first work is including +mainfile.php / header.php / admin_header.php, +and directory-protection stubs that only send HTTP 404/403. +

diff --git a/src/main/resources/inspectionDescriptions/XoopsUnregisteredTemplate.html b/src/main/resources/inspectionDescriptions/XoopsUnregisteredTemplate.html new file mode 100644 index 0000000..55fb402 --- /dev/null +++ b/src/main/resources/inspectionDescriptions/XoopsUnregisteredTemplate.html @@ -0,0 +1,10 @@ + + + +A .tpl file under the module templates/ or blocks/ +directory is not listed in xoops_version.php, so Smarty cannot +display() it as a registered module template. +

Theme overrides under themes/ are ignored.

+

Quick-fix appends a $modversion['templates'][] entry to the manifest.

+ + diff --git a/src/test/java/org/xoops/support/PluginDescriptorTest.java b/src/test/java/org/xoops/support/PluginDescriptorTest.java new file mode 100644 index 0000000..3d562bc --- /dev/null +++ b/src/test/java/org/xoops/support/PluginDescriptorTest.java @@ -0,0 +1,33 @@ +package org.xoops.support; + +import org.junit.Test; +import org.w3c.dom.Element; + +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.InputStream; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public final class PluginDescriptorTest { + + @Test + public void everyLocalInspectionHasADescriptionFile() throws Exception { + try (InputStream descriptor = getClass().getResourceAsStream("/META-INF/plugin.xml")) { + assertNotNull("plugin.xml must be on the test classpath", descriptor); + var document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(descriptor); + var inspections = document.getElementsByTagName("localInspection"); + assertTrue("expected the XOOPS inspection pack", inspections.getLength() >= 8); + for (int i = 0; i < inspections.getLength(); i++) { + Element inspection = (Element) inspections.item(i); + String shortName = inspection.getAttribute("shortName"); + assertFalse("shortName", shortName.isBlank()); + assertNotNull( + "missing inspectionDescriptions/" + shortName + ".html", + getClass().getResource("/inspectionDescriptions/" + shortName + ".html") + ); + } + } + } +} diff --git a/src/test/java/org/xoops/support/completion/XoopsLanguageConstantParserTest.java b/src/test/java/org/xoops/support/completion/XoopsLanguageConstantParserTest.java new file mode 100644 index 0000000..8a53352 --- /dev/null +++ b/src/test/java/org/xoops/support/completion/XoopsLanguageConstantParserTest.java @@ -0,0 +1,45 @@ +package org.xoops.support.completion; + +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public final class XoopsLanguageConstantParserTest { + + @Test + public void parsesDefineNamesIncludingSearchPhpStyleFiles() { + String php = """ + occ = XoopsLanguageConstantParser.parse(php); + assertEquals(5, occ.size()); + assertEquals("_MD_WGSIMPLEACC_FOO", occ.get(0).name()); + } + + @Test + public void extractNameAcceptsQuotedAndBare() { + assertEquals("_MD_FOO", XoopsLanguageConstantParser.extractName("'_MD_FOO'")); + assertEquals("_MD_FOO", XoopsLanguageConstantParser.extractName("\"_MD_FOO\"")); + assertEquals("_MD_FOO", XoopsLanguageConstantParser.extractName("_MD_FOO")); + assertNull(XoopsLanguageConstantParser.extractName("not_a_const")); + assertNull(XoopsLanguageConstantParser.extractName("'_XX_FOO'")); + } + + @Test + public void languagePathFilter() { + assertTrue(XoopsLanguageConstantParser.isLanguagePath("C:/m/language/english/search.php")); + assertTrue(XoopsLanguageConstantParser.isLanguagePath("/modules/news/language/german/mail.php")); + assertFalse(XoopsLanguageConstantParser.isLanguagePath("/modules/news/class/Item.php")); + } +} diff --git a/src/test/java/org/xoops/support/inspections/XoopsDeprecatedUnicodeDtypeTest.java b/src/test/java/org/xoops/support/inspections/XoopsDeprecatedUnicodeDtypeTest.java new file mode 100644 index 0000000..4cf1832 --- /dev/null +++ b/src/test/java/org/xoops/support/inspections/XoopsDeprecatedUnicodeDtypeTest.java @@ -0,0 +1,19 @@ +package org.xoops.support.inspections; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public final class XoopsDeprecatedUnicodeDtypeTest { + + @Test + public void successorMapCoversTheSixCoreAliases() { + assertEquals(6, XoopsDeprecatedUnicodeDtypeInspection.SUCCESSOR.size()); + assertEquals("XOBJ_DTYPE_TXTBOX", XoopsDeprecatedUnicodeDtypeInspection.SUCCESSOR.get("XOBJ_DTYPE_UNICODE_TXTBOX")); + assertEquals("XOBJ_DTYPE_TXTAREA", XoopsDeprecatedUnicodeDtypeInspection.SUCCESSOR.get("XOBJ_DTYPE_UNICODE_TXTAREA")); + assertEquals("XOBJ_DTYPE_URL", XoopsDeprecatedUnicodeDtypeInspection.SUCCESSOR.get("XOBJ_DTYPE_UNICODE_URL")); + assertEquals("XOBJ_DTYPE_EMAIL", XoopsDeprecatedUnicodeDtypeInspection.SUCCESSOR.get("XOBJ_DTYPE_UNICODE_EMAIL")); + assertEquals("XOBJ_DTYPE_ARRAY", XoopsDeprecatedUnicodeDtypeInspection.SUCCESSOR.get("XOBJ_DTYPE_UNICODE_ARRAY")); + assertEquals("XOBJ_DTYPE_OTHER", XoopsDeprecatedUnicodeDtypeInspection.SUCCESSOR.get("XOBJ_DTYPE_UNICODE_OTHER")); + } +} diff --git a/src/test/java/org/xoops/support/inspections/XoopsResultSetGuardInspectionTest.java b/src/test/java/org/xoops/support/inspections/XoopsResultSetGuardInspectionTest.java new file mode 100644 index 0000000..fd1a0be --- /dev/null +++ b/src/test/java/org/xoops/support/inspections/XoopsResultSetGuardInspectionTest.java @@ -0,0 +1,89 @@ +package org.xoops.support.inspections; + +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public final class XoopsResultSetGuardInspectionTest { + + @Test + public void unguardedFetchIsReported() { + String php = """ + query($sql); + while (list($sumIn, $sumOut) = $xoopsDB->fetchRow($result)) { + $amount += $sumIn; + } + """; + List hits = XoopsResultSetGuardInspection.unguardedFetchOffsets(php); + assertEquals(1, hits.size()); + } + + @Test + public void whileConditionFetchIsCoveredByPrecedingEarlyExit() { + String php = """ + query($sql); + if (!$xoopsDB->isResultSet($result) || !$result instanceof \\mysqli_result) { + throw new \\RuntimeException('Database query failed'); + } + while (list($sumIn, $sumOut) = $xoopsDB->fetchRow($result)) { + $amount += $sumIn; + } + """; + assertTrue(XoopsResultSetGuardInspection.unguardedFetchOffsets(php).isEmpty()); + } + + @Test + public void reassignmentClearsTheGuard() { + String php = """ + query($sql); + if (!$xoopsDB->isResultSet($result) || !$result instanceof \\mysqli_result) { + throw new \\RuntimeException('Database query failed'); + } + $result = $xoopsDB->query($sql2); + $row = $xoopsDB->fetchArray($result); + """; + assertEquals(1, XoopsResultSetGuardInspection.unguardedFetchOffsets(php).size()); + } + + @Test + public void positiveIfBodyIsGuarded() { + String php = """ + query($sql); + if ($db->isResultSet($result)) { + $row = $db->fetchArray($result); + } + """; + assertTrue(XoopsResultSetGuardInspection.unguardedFetchOffsets(php).isEmpty()); + } + + @Test + public void orFallbackIsNotASafePositiveGuard() { + String php = """ + query($sql); + if ($db->isResultSet($result) || $fallback) { + $row = $db->fetchArray($result); + } + """; + assertEquals(1, XoopsResultSetGuardInspection.unguardedFetchOffsets(php).size()); + } + + @Test + public void alreadyGuardedAboveDetectsInsertedBlock() { + String preceding = """ + if (!$xoopsDB->isResultSet($result) || !$result instanceof \\mysqli_result) { + throw new \\RuntimeException('Database query failed'); + } + """; + assertTrue(InsertBeforeStatementQuickFix.alreadyGuardedAbove(preceding, preceding.length(), "$result")); + assertFalse(InsertBeforeStatementQuickFix.alreadyGuardedAbove("while (true) {\n", 12, "$result")); + } +} diff --git a/src/test/java/org/xoops/support/inspections/XoopsRootPathGuardPolicyTest.java b/src/test/java/org/xoops/support/inspections/XoopsRootPathGuardPolicyTest.java new file mode 100644 index 0000000..160b04e --- /dev/null +++ b/src/test/java/org/xoops/support/inspections/XoopsRootPathGuardPolicyTest.java @@ -0,0 +1,118 @@ +package org.xoops.support.inspections; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public final class XoopsRootPathGuardPolicyTest { + + @Test + public void recognizesDieGuardAfterNamespaceAndUse() { + String source = """ + 0); + String before = source.substring(0, offset); + String after = source.substring(offset); + assertTrue(before.contains("namespace XoopsModules\\Wgsimpleacc;")); + assertFalse(before.contains("use Foo")); + assertTrue(after.stripLeading().startsWith("use Foo")); + } + + @Test + public void flagsUnguardedClassFile() { + assertTrue(XoopsRootPathGuardPolicy.requiresGuard( + "C:/site/htdocs/modules/wgsimpleacc/class/Accounts.php", + " 'demo_index.tpl', + 'description' => 'Index', + ]; + $modversion['templates'][] = ['template' => 'demo_block.tpl']; + // 'file' => 'commented.tpl' + """; + Set names = XoopsUnregisteredTemplateInspection.registeredTemplates(manifest); + assertTrue(names.contains("demo_index.tpl")); + assertTrue(names.contains("demo_block.tpl")); + assertFalse(names.contains("commented.tpl")); + } +} diff --git a/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java b/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java new file mode 100644 index 0000000..d8707ae --- /dev/null +++ b/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java @@ -0,0 +1,105 @@ +package org.xoops.support.scanner; + +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public final class XoopsProjectScannerTest { + + @Test + public void versionPatternsPinTheDocumentedCoreLines() { + assertTrue(XoopsProjectScanner.VERSION_25.matcher("XOOPS 2.5.11").find()); + assertTrue(XoopsProjectScanner.VERSION_27.matcher("XOOPS 2.7.3").find()); + assertTrue(XoopsProjectScanner.VERSION_40.matcher("XOOPS 4.0.0").find()); + assertFalse(XoopsProjectScanner.VERSION_25.matcher("XOOPS 2.7.3").find()); + assertFalse(XoopsProjectScanner.VERSION_27.matcher("version 12.5-extra").find()); + } + + @Test + public void manifestDirnameAndRegisteredTemplateRegexes() { + String manifest = """ + $modversion['dirname'] = 'wgsimpleacc'; + $modversion['templates'][] = ['file' => 'wgsimpleacc_index.tpl', 'description' => '']; + """; + var m = XoopsProjectScanner.REGISTERED_TEMPLATE.matcher(manifest); + assertTrue(m.find()); + assertEquals("wgsimpleacc_index.tpl", m.group(1)); + } + + @Test + public void mutatingQueryAndSmartyDelimiterRegexes() { + assertTrue(XoopsProjectScanner.MUTATING_QUERY.matcher("$db->query('INSERT INTO t')").find()); + assertFalse(XoopsProjectScanner.MUTATING_QUERY.matcher("$db->query('SELECT * FROM t')").find()); + assertTrue(XoopsProjectScanner.WRONG_SMARTY.matcher("{if $x}").find()); + assertFalse(XoopsProjectScanner.WRONG_SMARTY.matcher("<{if $x}>").find()); + assertTrue(XoopsProjectScanner.RAW_REQUEST.matcher("$_REQUEST['id']").find()); + assertTrue(XoopsProjectScanner.QUERY_F.matcher("$db->queryF($sql)").find()); + assertTrue(XoopsProjectScanner.QUOTE_STRING.matcher("$db->quoteString($s)").find()); + } + + @Test + public void inverseTemplateScan() throws Exception { + Path moduleRoot = Files.createTempDirectory("xoops-mod"); + try { + Files.writeString(moduleRoot.resolve("xoops_version.php"), """ + 'listed.tpl', 'description' => '']; + """); + Path templates = moduleRoot.resolve("templates"); + Files.createDirectories(templates); + Files.writeString(templates.resolve("listed.tpl"), "<{if $x}><{/if}>\n"); + Files.writeString(templates.resolve("orphan.tpl"), "<{if $y}><{/if}>\n"); + + List findings = new ArrayList<>(); + XoopsProjectScanner.checkRegisteredTemplates(moduleRoot, findings); + assertTrue(findings.stream().anyMatch(f -> "UNREGISTERED_TEMPLATE".equals(f.kind()) + && f.message().contains("orphan.tpl"))); + assertFalse(findings.stream().anyMatch(f -> f.message().contains("listed.tpl") + && "UNREGISTERED_TEMPLATE".equals(f.kind()))); + } finally { + deleteRecursively(moduleRoot); + } + } + + @Test + public void missingRegisteredTemplateScan() throws Exception { + Path moduleRoot = Files.createTempDirectory("xoops-mod"); + try { + Files.writeString(moduleRoot.resolve("xoops_version.php"), """ + 'ghost.tpl', 'description' => '']; + """); + Files.createDirectories(moduleRoot.resolve("templates")); + List findings = new ArrayList<>(); + XoopsProjectScanner.checkRegisteredTemplates(moduleRoot, findings); + assertTrue(findings.stream().anyMatch(f -> "MISSING_REGISTERED_TEMPLATE".equals(f.kind()) + && f.message().contains("ghost.tpl"))); + } finally { + deleteRecursively(moduleRoot); + } + } + + private static void deleteRecursively(Path root) throws Exception { + if (!Files.exists(root)) { + return; + } + try (var walk = Files.walk(root)) { + walk.sorted((a, b) -> b.getNameCount() - a.getNameCount()) + .forEach(p -> { + try { + Files.deleteIfExists(p); + } catch (Exception ignored) { + // temp cleanup + } + }); + } + } +} diff --git a/test-fixtures/index_404_stub.php b/test-fixtures/index_404_stub.php new file mode 100644 index 0000000..6a50543 --- /dev/null +++ b/test-fixtures/index_404_stub.php @@ -0,0 +1,2 @@ +query($sql); + if (!$xoopsDB->isResultSet($result) || !$result instanceof \mysqli_result) { + throw new RuntimeException('Database query failed'); + } + while (list($sumIn, $sumOut) = $xoopsDB->fetchRow($result)) { + $unused = $sumIn + $sumOut; + } + + $result = $xoopsDB->query('SELECT 2'); + $row = $xoopsDB->fetchArray($result); // should warn — $result was reassigned + } +} diff --git a/whats-new.html b/whats-new.html index 37294d3..634e5bb 100644 --- a/whats-new.html +++ b/whats-new.html @@ -1,4 +1,18 @@ +

1.0.0 Alpha 3

+

Field-report fixes from wgSimpleAcc plus language-constant navigation and two new inspections.

+
    +
  • Inspections no longer duplicate every finding (PHP+HTML PSI)
  • +
  • isResultSet quick-fix is safe in Inspect Code batch apply; early-exit guards cover while (list = fetchRow)
  • +
  • ROOT_PATH guard: after namespace, accepts die, skips 404 stubs and admin/bootstrap entry points
  • +
  • Language constants from every language/**/*.php; Ctrl+B / Find Usages
  • +
  • Unregistered .tpl inspection; XOBJ_DTYPE_UNICODE_* rename quick-fix
  • +
+

1.0.0 Alpha 2

+
    +
  • On-demand Overview scan (no monorepo boot freeze); cancellable scans
  • +
  • Inspections under top-level XOOPS group
  • +

1.0.0 Alpha 1

First public alpha of XOOPS Support.

    From 9e3921d879176663195238136d278f532e6f0b4f Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 21 Sep 2026 01:39:03 -0400 Subject: [PATCH 02/20] fix: address review findings on isResultSet, ROOT_PATH guard and constants Alpha 3 review follow-ups from the PR bots and a second review pass. - Persist the Alpha 2 coreProfile setting into coreVersion on load; explicit Auto in the new key wins over a legacy value. - isResultSet batch quick-fix reuses the inspection's guard analysis at the fetch offset instead of a substring window. A reassignment of the result variable completes at ";", ",", an unmatched closer, or a depth-0 or/and/xor; ?:, ??, || and && keep the fetch inside the assignment. Reassignment inside a positive if is unguarded. - ROOT_PATH guard: short " { if (project.isDisposed()) { return; } - ApplicationManager.getApplication().executeOnPooledThread(() -> { - if (project.isDisposed()) { - return; - } - XoopsSettingsState current = XoopsSettingsState.getInstance(project); - if (!current.enabled || current.suppressStartupNotification) { - return; - } - XoopsProjectService service = XoopsProjectService.getInstance(project); - if (!service.isXoopsProject()) { - return; - } - int modules = service.findModuleDirnames().size(); - ApplicationManager.getApplication().invokeLater( - () -> { - if (project.isDisposed()) { - return; - } - NotificationGroupManager.getInstance() - .getNotificationGroup("XOOPS Support") - .createNotification( - "XOOPS Support active", - "Detected XOOPS markers (" + modules + " module(s) with xoops_version.php). " - + "See Settings → Editor → Inspections → XOOPS, " - + "and Tools → XOOPS Support.", - NotificationType.INFORMATION - ) - .notify(project); - }, - ModalityState.nonModal(), - project.getDisposed() - ); - }); + ApplicationManager.getApplication().executeOnPooledThread(() -> notifyIfXoops(project)); }); } + + private static boolean notificationsEnabled(@NotNull Project project) { + XoopsSettingsState settings = XoopsSettingsState.getInstance(project); + return settings.enabled && !settings.suppressStartupNotification; + } + + private static void notifyIfXoops(@NotNull Project project) { + if (project.isDisposed() || !notificationsEnabled(project)) { + return; + } + XoopsProjectService service = XoopsProjectService.getInstance(project); + if (!service.isXoopsProject()) { + return; + } + int modules = service.findModuleDirnames().size(); + ApplicationManager.getApplication().invokeLater( + () -> showBalloon(project, modules), + ModalityState.nonModal(), + project.getDisposed() + ); + } + + private static void showBalloon(@NotNull Project project, int modules) { + if (project.isDisposed()) { + return; + } + NotificationGroupManager.getInstance() + .getNotificationGroup("XOOPS Support") + .createNotification( + "XOOPS Support active", + "Detected XOOPS markers (" + modules + " module(s) with xoops_version.php). " + + "See Settings → Editor → Inspections → XOOPS, " + + "and Tools → XOOPS Support.", + NotificationType.INFORMATION + ) + .notify(project); + } } diff --git a/src/main/java/org/xoops/support/completion/XoopsLanguageConstantParser.java b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantParser.java index aee2d74..64516e0 100644 --- a/src/main/java/org/xoops/support/completion/XoopsLanguageConstantParser.java +++ b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantParser.java @@ -41,7 +41,8 @@ private XoopsLanguageConstantParser() { /** * Extract a XOOPS language-constant name from a PSI element's text - * (quoted string, bare identifier, or Smarty token). + * (quoted string, bare identifier, {@code $smarty.const._MI_FOO}, or Smarty token). + * Recognition is case-insensitive; the returned spelling is the original. */ public static @Nullable String extractName(@NotNull String elementText) { String trimmed = elementText.strip(); @@ -52,8 +53,15 @@ private XoopsLanguageConstantParser() { trimmed = trimmed.substring(1, trimmed.length() - 1); } } + if (trimmed.startsWith("<{") && trimmed.endsWith("}>") && trimmed.length() > 4) { + trimmed = trimmed.substring(2, trimmed.length() - 2).strip(); + } String upper = trimmed.toUpperCase(Locale.ROOT); - return CONSTANT_NAME.matcher(upper).matches() ? upper : null; + if (upper.startsWith("$SMARTY.CONST.")) { + trimmed = trimmed.substring("$SMARTY.CONST.".length()); + upper = trimmed.toUpperCase(Locale.ROOT); + } + return CONSTANT_NAME.matcher(upper).matches() ? trimmed : null; } public static boolean isLanguagePath(@Nullable String path) { diff --git a/src/main/java/org/xoops/support/completion/XoopsLanguageConstantReferenceContributor.java b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantReferenceContributor.java index 5a04f02..51eccd8 100644 --- a/src/main/java/org/xoops/support/completion/XoopsLanguageConstantReferenceContributor.java +++ b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantReferenceContributor.java @@ -1,5 +1,6 @@ package org.xoops.support.completion; +import com.intellij.lang.Language; import com.intellij.patterns.PlatformPatterns; import com.intellij.psi.PsiElement; import com.intellij.psi.PsiReference; @@ -37,6 +38,13 @@ public void registerReferenceProviders(@NotNull PsiReferenceRegistrar registrar) }; registrar.registerReferenceProvider(PlatformPatterns.psiElement(StringLiteralExpression.class), provider); registrar.registerReferenceProvider(PlatformPatterns.psiElement(ConstantReference.class), provider); + Language smarty = Language.findLanguageByID("Smarty"); + if (smarty != null) { + registrar.registerReferenceProvider( + PlatformPatterns.psiElement().withLanguage(smarty), + provider + ); + } } private static String nameOf(@NotNull PsiElement element) { @@ -47,7 +55,8 @@ private static String nameOf(@NotNull PsiElement element) { String n = constantReference.getName(); return n == null ? null : XoopsLanguageConstantParser.extractName(n); } - // Smarty tokens and other leaves: only if the whole text is the constant. + // Smarty (and any other host): only leaves. A parent whose text is + // <{$smarty.const._MI_FOO}> would otherwise double-count with the leaf. if (element.getChildren().length == 0) { return XoopsLanguageConstantParser.extractName(element.getText()); } diff --git a/src/main/java/org/xoops/support/completion/XoopsLanguageConstantsCache.java b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantsCache.java index 1db2c22..16f9010 100644 --- a/src/main/java/org/xoops/support/completion/XoopsLanguageConstantsCache.java +++ b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantsCache.java @@ -102,6 +102,7 @@ public void invalidate() { /** * PSI element of the {@code define('_FOO_'} name in a language file (prefers {@code /english/}). + * Exact spelling only: {@code _MI_FOO} does not resolve to {@code define('_mi_foo')}. */ public @Nullable PsiElement resolve(@NotNull String name) { Index index = getIndex(); diff --git a/src/main/java/org/xoops/support/inspections/InsertBeforeStatementQuickFix.java b/src/main/java/org/xoops/support/inspections/InsertBeforeStatementQuickFix.java index 647998b..0d3e23b 100644 --- a/src/main/java/org/xoops/support/inspections/InsertBeforeStatementQuickFix.java +++ b/src/main/java/org/xoops/support/inspections/InsertBeforeStatementQuickFix.java @@ -51,7 +51,8 @@ public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descri return; } String text = document.getText(); - if (alreadyGuardedAbove(text, insertAt, resultVar)) { + int fetchOffset = leaf.getTextRange().getStartOffset(); + if (XoopsResultSetGuardInspection.isFetchGuardedAt(text, fetchOffset, resultVar)) { return; } String indent = guessIndent(text, insertAt); @@ -63,17 +64,6 @@ public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descri PsiDocumentManager.getInstance(project).commitDocument(document); } - /** - * True when the statement immediately above {@code insertAt} already throws/returns - * on {@code !isResultSet($var)}. Prevents a second insert if a stale duplicate finding - * is applied after the first fix in the same Inspect Code session. - */ - static boolean alreadyGuardedAbove(@NotNull String text, int insertAt, @NotNull String resultVar) { - int from = Math.max(0, insertAt - 400); - String preceding = text.substring(from, insertAt); - return preceding.contains("isResultSet(" + resultVar + ")"); - } - private static String guessIndent(@NotNull String text, int offset) { int start = text.lastIndexOf('\n', Math.max(0, offset - 1)); start = start < 0 ? 0 : start + 1; diff --git a/src/main/java/org/xoops/support/inspections/InsertRootPathGuardQuickFix.java b/src/main/java/org/xoops/support/inspections/InsertRootPathGuardQuickFix.java index 215a6a3..200c084 100644 --- a/src/main/java/org/xoops/support/inspections/InsertRootPathGuardQuickFix.java +++ b/src/main/java/org/xoops/support/inspections/InsertRootPathGuardQuickFix.java @@ -20,11 +20,6 @@ public final class InsertRootPathGuardQuickFix implements LocalQuickFix { private static final String GUARD = "defined('XOOPS_ROOT_PATH') || exit('Restricted access');\n"; private static final Pattern OPEN_ECHO = Pattern.compile("<\\?=", Pattern.CASE_INSENSITIVE); - /** Short open tag: {@code \n"); @@ -85,7 +74,7 @@ private static void insertGuardAt( @NotNull Project project, int offset ) { - int insertAt = Math.max(0, Math.min(offset, document.getTextLength())); + int insertAt = Math.clamp(offset, 0, document.getTextLength()); CharSequence seq = document.getCharsSequence(); String prefix = insertAt == 0 ? "" : String.valueOf(seq.charAt(insertAt - 1)); String toInsert = GUARD; diff --git a/src/main/java/org/xoops/support/inspections/RegisterTemplateQuickFix.java b/src/main/java/org/xoops/support/inspections/RegisterTemplateQuickFix.java index e88b68b..8b7cf2c 100644 --- a/src/main/java/org/xoops/support/inspections/RegisterTemplateQuickFix.java +++ b/src/main/java/org/xoops/support/inspections/RegisterTemplateQuickFix.java @@ -10,6 +10,8 @@ import com.intellij.psi.PsiManager; import org.jetbrains.annotations.NotNull; +import java.util.Locale; + /** * Appends a {@code $modversion['templates'][]} entry for the unregistered .tpl. */ @@ -55,8 +57,10 @@ public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descri return; } String text = document.getText(); - if (text.toLowerCase().contains("'" + templateName.toLowerCase() + "'") - || text.toLowerCase().contains("\"" + templateName.toLowerCase() + "\"")) { + String lowerText = text.toLowerCase(Locale.ROOT); + String lowerName = templateName.toLowerCase(Locale.ROOT); + if (lowerText.contains("'" + lowerName + "'") + || lowerText.contains("\"" + lowerName + "\"")) { return; } String entry = "\n$modversion['templates'][] = [\n" diff --git a/src/main/java/org/xoops/support/inspections/XoopsDeprecatedUnicodeDtypeInspection.java b/src/main/java/org/xoops/support/inspections/XoopsDeprecatedUnicodeDtypeInspection.java index 1952f5b..32aeae9 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsDeprecatedUnicodeDtypeInspection.java +++ b/src/main/java/org/xoops/support/inspections/XoopsDeprecatedUnicodeDtypeInspection.java @@ -44,8 +44,7 @@ public void visitFile(@NotNull PsiFile file) { if (!PhpTextUtil.isPhpFile(file) || PhpTextUtil.looksLikeVendorOrCache(file)) { return; } - String coreVersion = XoopsSettingsState.getInstance(file.getProject()).coreVersion; - if ("2.5".equals(coreVersion)) { + if ("2.5".equals(XoopsSettingsState.getInstance(file.getProject()).resolvedCoreVersion())) { return; } String text = file.getText(); @@ -54,18 +53,14 @@ public void visitFile(@NotNull PsiFile file) { while (m.find()) { String old = m.group(1); String next = SUCCESSOR.get(old); - if (next == null) { - continue; + PsiElement leaf = next == null ? null : PhpTextUtil.leafAt(file, m.start(1)); + if (next != null && leaf != null) { + holder.registerProblem( + leaf, + "XOOPS: " + old + " is deprecated since 2.7.3; use " + next, + new ReplacePsiTextQuickFix("Replace with " + next, next) + ); } - PsiElement leaf = PhpTextUtil.leafAt(file, m.start(1)); - if (leaf == null) { - continue; - } - holder.registerProblem( - leaf, - "XOOPS: " + old + " is deprecated since 2.7.3; use " + next, - new ReplacePsiTextQuickFix("Replace with " + next, next) - ); } } }; diff --git a/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java b/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java index d61a861..50301f2 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java +++ b/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java @@ -112,44 +112,66 @@ public void visitFile(@NotNull PsiFile file) { }; } + /** + * True when {@code fetchOffset} in {@code text} is already proven-safe for {@code resultVar}. + * Used by the batch quick-fix on the current document (not a raw substring window). + */ + static boolean isFetchGuardedAt(@NotNull String text, int fetchOffset, @NotNull String resultVar) { + String code = PhpTextUtil.maskCommentsAndStrings(text); + if (fetchOffset < 0 || fetchOffset >= code.length()) { + return false; + } + return isFetchAlreadyGuarded(code, fetchOffset, resultVar, findIfConditions(code)); + } + private static boolean isFetchAlreadyGuarded( @NotNull String code, int fetchOffset, @NotNull String resultVar, @NotNull List allIfs ) { - List before = new ArrayList<>(); - for (IfCond ic : allIfs) { - if (ic.ifStart < fetchOffset) { - before.add(ic); - } - } + List before = ifsBefore(allIfs, fetchOffset); if (before.isEmpty()) { return false; } - if (isInsidePositiveIsResultSetGuard(code, fetchOffset, resultVar, before)) { return true; } + return hasDominatingEarlyExit(code, fetchOffset, resultVar, before); + } - // Dominating early-exit: a proven !isResultSet($var) throw/return covers later - // fetch* of $var (including while/for conditions) until $var is reassigned, - // a nested function appears, or a '}' closes the enclosing block. + private static @NotNull List ifsBefore(@NotNull List allIfs, int fetchOffset) { + List before = new ArrayList<>(); + for (IfCond ic : allIfs) { + if (ic.ifStart < fetchOffset) { + before.add(ic); + } + } + return before; + } + + /** + * Dominating early-exit: a proven {@code !isResultSet($var)} throw/return covers later + * fetch* of {@code $var} until reassignment, a nested function, or a closing {@code }}. + */ + private static boolean hasDominatingEarlyExit( + @NotNull String code, + int fetchOffset, + @NotNull String resultVar, + @NotNull List before + ) { for (int i = before.size() - 1; i >= 0; i--) { IfCond ic = before.get(i); if (ic.ifEnd > fetchOffset) { continue; } - if (!isSafeEarlyExitCondition(ic.condition, resultVar) || !bodyIsSimpleEarlyExit(code, ic)) { - continue; - } - if (assignsResultVar(code, ic.ifEnd, fetchOffset, resultVar)) { - continue; - } - if (closesOutOfScope(code, ic.ifEnd, fetchOffset) || hasFunctionKeyword(code, ic.ifEnd, fetchOffset)) { - continue; + if (isSafeEarlyExitCondition(ic.condition, resultVar) + && bodyIsSimpleEarlyExit(code, ic) + && !assignsResultVar(code, ic.ifEnd, fetchOffset, resultVar) + && !closesOutOfScope(code, ic.ifEnd, fetchOffset) + && !hasFunctionKeyword(code, ic.ifEnd, fetchOffset)) { + return true; } - return true; } return false; } @@ -181,12 +203,62 @@ private static boolean assignsResultVar( Matcher m = assign.matcher(code); while (m.find()) { if (m.start() >= from && m.start() < to) { - return true; + if (assignmentRhsEnd(code, m.start()) <= to) { + return true; + } } } return false; } + /** + * End offset of the right-hand side of the assignment starting at {@code assignStart} + * (the {@code $} of {@code $var = …}): the first {@code ;} or {@code ,} at depth 0, + * an unmatched closer, or a depth-0 {@code or} / {@code and} / {@code xor}, which bind + * looser than {@code =}. {@code ?:}, {@code ??}, {@code ||}, {@code &&} bind tighter, + * so they stay inside the right-hand side. A fetch inside that range reads the old value; + * a fetch after it ({@code ($r = false) || fetch($r)}, {@code $r = false or fetch($r)}) + * sees the new value. + */ + private static int assignmentRhsEnd(@NotNull String code, int assignStart) { + int depth = 0; + for (int i = assignStart; i < code.length(); i++) { + char c = code.charAt(i); + if (c == '(' || c == '[' || c == '{') { + depth++; + } else if (c == ')' || c == ']' || c == '}') { + if (depth == 0) { + return i; + } + depth--; + } else if (depth == 0 && (c == ';' || c == ',')) { + return i; + } else if (depth == 0 && startsWordOperator(code, i)) { + return i; + } + } + return code.length(); + } + + private static final Pattern WORD_OPERATOR = Pattern.compile("(?i)(?:or|and|xor)\\b"); + + /** True when a PHP {@code or} / {@code and} / {@code xor} keyword starts at {@code i}. */ + private static boolean startsWordOperator(@NotNull String code, int i) { + char c = Character.toLowerCase(code.charAt(i)); + if (c != 'o' && c != 'a' && c != 'x') { + return false; + } + if (i > 0) { + char prev = code.charAt(i - 1); + if (Character.isLetterOrDigit(prev) || prev == '_' || prev == '$') { + return false; + } + } + Matcher m = WORD_OPERATOR.matcher(code); + m.region(i, Math.min(code.length(), i + 4)); + return m.lookingAt(); + } + private static boolean closesOutOfScope(@NotNull String code, int from, int to) { int depth = 0; int end = Math.min(to, code.length()); @@ -438,11 +510,10 @@ private static boolean isInsidePositiveIsResultSetGuard( } } if (depth > 0) { - return true; + return !assignsResultVar(code, ic.openBrace + 1, fetchOffset, resultVar); } } else if (fetchOffset > ic.condEnd && fetchOffset < ic.ifEnd) { - // Brace-less: if (isResultSet($r)) $row = $db->fetch...; - return true; + return !assignsResultVar(code, ic.condEnd + 1, fetchOffset, resultVar); } } return false; diff --git a/src/main/java/org/xoops/support/inspections/XoopsRootPathGuardPolicy.java b/src/main/java/org/xoops/support/inspections/XoopsRootPathGuardPolicy.java index ca2c5a7..2cd0656 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsRootPathGuardPolicy.java +++ b/src/main/java/org/xoops/support/inspections/XoopsRootPathGuardPolicy.java @@ -2,6 +2,8 @@ import org.jetbrains.annotations.NotNull; +import java.util.ArrayList; +import java.util.List; import java.util.Locale; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -13,39 +15,39 @@ public final class XoopsRootPathGuardPolicy { private static final Pattern OPEN_PHP = Pattern.compile("<\\?php\\b", Pattern.CASE_INSENSITIVE); + /** {@code splitStatements(@NotNull String code) { + List out = new ArrayList<>(); + char quote = 0; + int start = 0; + for (int i = 0; i < code.length(); i++) { + char c = code.charAt(i); + if (quote != 0) { + if (c == '\\') { + i++; + } else if (c == quote) { + quote = 0; + } + } else if (c == '\'' || c == '"') { + quote = c; + } else if (c == ';') { + out.add(code.substring(start, i)); + start = i + 1; + } + } + out.add(code.substring(start)); + return out; } static boolean isBootstrapEntryPoint(@NotNull String firstExecutable) { @@ -147,27 +179,17 @@ static boolean isBootstrapEntryPoint(@NotNull String firstExecutable) { } /** - * Comment-masked source from the first executable statement after {@code = masked.length()) { return ""; } @@ -175,6 +197,67 @@ static boolean isBootstrapEntryPoint(@NotNull String firstExecutable) { return rest.replaceFirst("(?s)\\?>\\s*$", "").strip(); } + /** Any {@code = 0; + } + + /** End offset of the first {@code registered = registeredTemplates(manifestPsi.getText()); @@ -77,6 +57,25 @@ public void visitFile(@NotNull PsiFile file) { }; } + private static @Nullable VirtualFile candidateTpl(@NotNull PsiFile file) { + if (!XoopsSupportPlugin.isEnabled(file) || !PhpTextUtil.isPrimaryPsiFile(file)) { + return null; + } + String name = file.getName().toLowerCase(Locale.ROOT); + if (!name.endsWith(".tpl") || PhpTextUtil.looksLikeVendorOrCache(file)) { + return null; + } + VirtualFile vf = file.getVirtualFile(); + if (vf == null) { + return null; + } + String path = vf.getPath().replace('\\', '/').toLowerCase(Locale.ROOT); + if (path.contains("/themes/") || path.contains("/templates_c/")) { + return null; + } + return vf; + } + static @NotNull Set registeredTemplates(@NotNull String manifestText) { Set out = new LinkedHashSet<>(); Matcher m = REGISTERED_TEMPLATE.matcher(PhpTextUtil.maskCommentsOnly(manifestText)); diff --git a/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java b/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java index 6ef394c..b66e719 100644 --- a/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java +++ b/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java @@ -35,9 +35,9 @@ public final class XoopsProjectScanner { "smarty_compile", "templates_c", "uploads", "vendor", "xoops_data" ); - static final Pattern VERSION_25 = Pattern.compile("(?i)(?:XOOPS[ _-]?)?2\\.5(?:\\.|[^0-9]|$)"); - static final Pattern VERSION_27 = Pattern.compile("(?i)(?:XOOPS[ _-]?)?2\\.7(?:\\.|[^0-9]|$)"); - static final Pattern VERSION_40 = Pattern.compile("(?i)(?:XOOPS[ _-]?)?4\\.0(?:\\.|[^0-9]|$)"); + static final Pattern VERSION_25 = Pattern.compile("(?i)(?:XOOPS[ _-]?)?2\\.5(?:[^0-9]|$)"); + static final Pattern VERSION_27 = Pattern.compile("(?i)(?:XOOPS[ _-]?)?2\\.7(?:[^0-9]|$)"); + static final Pattern VERSION_40 = Pattern.compile("(?i)(?:XOOPS[ _-]?)?4\\.0(?:[^0-9]|$)"); static final Pattern REGISTERED_TEMPLATE = Pattern.compile( "(?is)['\"](?:file|template)['\"]\\s*=>\\s*['\"]([^'\"]+\\.tpl)['\"]" ); diff --git a/src/main/java/org/xoops/support/settings/XoopsConfigurable.java b/src/main/java/org/xoops/support/settings/XoopsConfigurable.java index 6ac8ba5..b1d5e6b 100644 --- a/src/main/java/org/xoops/support/settings/XoopsConfigurable.java +++ b/src/main/java/org/xoops/support/settings/XoopsConfigurable.java @@ -83,7 +83,7 @@ public XoopsConfigurable(Project project) { public boolean isModified() { XoopsSettingsState s = XoopsSettingsState.getInstance(project); String selectedCoreVersion = String.valueOf(coreVersionBox.getSelectedItem()); - String storedCoreVersion = s.coreVersion == null ? "Auto" : s.coreVersion; + String storedCoreVersion = s.resolvedCoreVersion(); String storedPrefix = s.tablePrefix == null ? "" : s.tablePrefix; return enabledBox.isSelected() != s.enabled || suppressNotifyBox.isSelected() != s.suppressStartupNotification @@ -108,7 +108,7 @@ public void reset() { enabledBox.setSelected(s.enabled); suppressNotifyBox.setSelected(s.suppressStartupNotification); autoScanBox.setSelected(s.autoScanOnToolWindowOpen); - coreVersionBox.setSelectedItem(s.coreVersion == null ? "Auto" : s.coreVersion); + coreVersionBox.setSelectedItem(s.resolvedCoreVersion()); prefixField.setText(s.tablePrefix == null ? "" : s.tablePrefix); } } diff --git a/src/main/java/org/xoops/support/settings/XoopsSettingsState.java b/src/main/java/org/xoops/support/settings/XoopsSettingsState.java index f1b3d55..85a89ce 100644 --- a/src/main/java/org/xoops/support/settings/XoopsSettingsState.java +++ b/src/main/java/org/xoops/support/settings/XoopsSettingsState.java @@ -8,6 +8,7 @@ import com.intellij.util.xmlb.XmlSerializerUtil; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; @Service(Service.Level.PROJECT) @State(name = "XoopsSupportSettings", storages = @Storage("xoopsSupport.xml")) @@ -21,21 +22,68 @@ public final class XoopsSettingsState implements PersistentStateComponent - + + +Deprecated XOBJ_DTYPE_UNICODE_* + XOBJ_DTYPE_UNICODE_* constants were deprecated in XOOPS 2.7.3 (core issue #164) and are scheduled for removal in 4.0. diff --git a/src/main/resources/inspectionDescriptions/XoopsUnregisteredTemplate.html b/src/main/resources/inspectionDescriptions/XoopsUnregisteredTemplate.html index 55fb402..975e47b 100644 --- a/src/main/resources/inspectionDescriptions/XoopsUnregisteredTemplate.html +++ b/src/main/resources/inspectionDescriptions/XoopsUnregisteredTemplate.html @@ -1,5 +1,8 @@ - + + +Unregistered XOOPS template + A .tpl file under the module templates/ or blocks/ directory is not listed in xoops_version.php, so Smarty cannot diff --git a/src/test/java/org/xoops/support/completion/XoopsLanguageConstantParserTest.java b/src/test/java/org/xoops/support/completion/XoopsLanguageConstantParserTest.java index 8a53352..6c820d0 100644 --- a/src/test/java/org/xoops/support/completion/XoopsLanguageConstantParserTest.java +++ b/src/test/java/org/xoops/support/completion/XoopsLanguageConstantParserTest.java @@ -36,6 +36,27 @@ public void extractNameAcceptsQuotedAndBare() { assertNull(XoopsLanguageConstantParser.extractName("'_XX_FOO'")); } + @Test + public void parsePreservesDistinctSpellings() { + String php = """ + occ = XoopsLanguageConstantParser.parse(php); + assertEquals(2, occ.size()); + assertEquals("_mi_foo", occ.get(0).name()); + assertEquals("_MI_FOO", occ.get(1).name()); + } + + @Test + public void extractNamePreservesSpellingAndSmartyConst() { + assertEquals("_mi_foo", XoopsLanguageConstantParser.extractName("_mi_foo")); + assertEquals("_MI_FOO", XoopsLanguageConstantParser.extractName("$smarty.const._MI_FOO")); + assertEquals("_MI_FOO", XoopsLanguageConstantParser.extractName("<{$smarty.const._MI_FOO}>")); + assertNull(XoopsLanguageConstantParser.extractName("'customer.const._MI_FOO'")); + } + @Test public void languagePathFilter() { assertTrue(XoopsLanguageConstantParser.isLanguagePath("C:/m/language/english/search.php")); diff --git a/src/test/java/org/xoops/support/inspections/XoopsResultSetGuardInspectionTest.java b/src/test/java/org/xoops/support/inspections/XoopsResultSetGuardInspectionTest.java index fd1a0be..1b61e69 100644 --- a/src/test/java/org/xoops/support/inspections/XoopsResultSetGuardInspectionTest.java +++ b/src/test/java/org/xoops/support/inspections/XoopsResultSetGuardInspectionTest.java @@ -77,13 +77,96 @@ public void orFallbackIsNotASafePositiveGuard() { } @Test - public void alreadyGuardedAboveDetectsInsertedBlock() { - String preceding = """ - if (!$xoopsDB->isResultSet($result) || !$result instanceof \\mysqli_result) { - throw new \\RuntimeException('Database query failed'); + public void reassignmentInsidePositiveGuardIsUnguarded() { + String php = """ + query($sql); + if ($db->isResultSet($result)) { + $result = $db->query($sql2); + $row = $db->fetchArray($result); + } + """; + assertEquals(1, XoopsResultSetGuardInspection.unguardedFetchOffsets(php).size()); + } + + @Test + public void fetchOnRightHandSideOfAssignmentIsStillGuarded() { + String php = """ + query($sql); + if ($db->isResultSet($result)) { + $result = $db->fetchArray($result); + } + """; + assertTrue(XoopsResultSetGuardInspection.unguardedFetchOffsets(php).isEmpty()); + } + + @Test + public void completedAssignmentBeforeFetchInSameStatementIsUnguarded() { + String php = """ + query($sql); + if (!$db->isResultSet($result)) { + return; + } + ($result = false) || $db->fetchArray($result); + """; + assertEquals(1, XoopsResultSetGuardInspection.unguardedFetchOffsets(php).size()); + int fetchAt = php.indexOf("fetchArray"); + assertFalse(XoopsResultSetGuardInspection.isFetchGuardedAt(php, fetchAt, "$result")); + } + + @Test + public void wordOrOperatorCompletesAssignmentBeforeFetch() { + String php = """ + query($sql); + if (!$db->isResultSet($result)) { + return; + } + $result = false or $db->fetchArray($result); + """; + assertEquals(1, XoopsResultSetGuardInspection.unguardedFetchOffsets(php).size()); + } + + @Test + public void tightBindingOperatorsKeepFetchInsideAssignment() { + for (String op : new String[] {"?:", "??", "||", "&&"}) { + String php = """ + query($sql); + if ($db->isResultSet($result)) { + $result = $a %s $db->fetchArray($result); } + """.formatted(op); + assertTrue(op, XoopsResultSetGuardInspection.unguardedFetchOffsets(php).isEmpty()); + } + } + + @Test + public void commentContainingIsResultSetDoesNotGuard() { + String php = """ + query($sql); + // if (!$db->isResultSet($result)) { return; } + $row = $db->fetchArray($result); + """; + assertEquals(1, XoopsResultSetGuardInspection.unguardedFetchOffsets(php).size()); + int fetchAt = php.indexOf("fetchArray"); + assertFalse(XoopsResultSetGuardInspection.isFetchGuardedAt(php, fetchAt, "$result")); + } + + @Test + public void earlyExitStillGuardsLaterFetch() { + String php = """ + query($sql); + if (!$db->isResultSet($result)) { + throw new \\RuntimeException('fail'); + } + $row = $db->fetchArray($result); """; - assertTrue(InsertBeforeStatementQuickFix.alreadyGuardedAbove(preceding, preceding.length(), "$result")); - assertFalse(InsertBeforeStatementQuickFix.alreadyGuardedAbove("while (true) {\n", 12, "$result")); + int fetchAt = php.indexOf("fetchArray"); + assertTrue(XoopsResultSetGuardInspection.isFetchGuardedAt(php, fetchAt, "$result")); } } diff --git a/src/test/java/org/xoops/support/inspections/XoopsRootPathGuardPolicyTest.java b/src/test/java/org/xoops/support/inspections/XoopsRootPathGuardPolicyTest.java index 160b04e..80b3b17 100644 --- a/src/test/java/org/xoops/support/inspections/XoopsRootPathGuardPolicyTest.java +++ b/src/test/java/org/xoops/support/inspections/XoopsRootPathGuardPolicyTest.java @@ -74,6 +74,35 @@ public void skips404StubIndex() { )); } + @Test + public void responseCodePrefixWithMoreWorkIsNotAStub() { + String source = "\n"; + assertTrue(XoopsRootPathGuardPolicy.requiresGuard( + "C:/site/htdocs/modules/demo/include/view.php", + source + )); + assertEquals(-1, XoopsRootPathGuardPolicy.insertOffset(source)); + } + + @Test + public void semicolonInsideQuotedHeaderIsStillAStub() { + assertTrue(XoopsRootPathGuardPolicy.is404OrForbiddenStub( + "header('HTTP/1.1 403 Forbidden; denied');" + )); + } + @Test public void skipsBootstrapEntryPoint() { assertFalse(XoopsRootPathGuardPolicy.requiresGuard( @@ -115,4 +144,47 @@ public void insertOffsetPlainFileIsAfterOpenTag() { int offset = XoopsRootPathGuardPolicy.insertOffset(source); assertEquals("class Foo {}\n", source.substring(offset)); } + + @Test + public void shortOpenTagIsDetectedAndInsertsAfterNamespace() { + String source = " 0); + String before = source.substring(0, offset); + assertTrue(before.contains("namespace Example;")); + assertFalse(before.contains("namespace Example;\nuse")); + assertTrue(source.substring(offset).stripLeading().startsWith("use Foo")); + } + + @Test + public void multipleDeclareStatementsAreSkipped() { + String source = """ + b.getNameCount() - a.getNameCount()) + walk.sorted((a, b) -> Integer.compare(b.getNameCount(), a.getNameCount())) .forEach(p -> { try { Files.deleteIfExists(p); diff --git a/src/test/java/org/xoops/support/settings/XoopsSettingsStateTest.java b/src/test/java/org/xoops/support/settings/XoopsSettingsStateTest.java new file mode 100644 index 0000000..f0f26f8 --- /dev/null +++ b/src/test/java/org/xoops/support/settings/XoopsSettingsStateTest.java @@ -0,0 +1,49 @@ +package org.xoops.support.settings; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public final class XoopsSettingsStateTest { + + @Test + public void legacyOnlyUsesCoreProfile() { + XoopsSettingsState s = XoopsSettingsState.migrateForTest(null, "2.5"); + assertEquals("2.5", s.coreVersion); + assertNull(s.coreProfile); + } + + @Test + public void newOnlyKeepsCoreVersion() { + XoopsSettingsState s = XoopsSettingsState.migrateForTest("2.7", null); + assertEquals("2.7", s.coreVersion); + assertNull(s.coreProfile); + } + + @Test + public void bothKeysPreferCoreVersionIncludingExplicitAuto() { + XoopsSettingsState s = XoopsSettingsState.migrateForTest("Auto", "2.5"); + assertEquals("Auto", s.coreVersion); + assertNull(s.coreProfile); + } + + @Test + public void bothKeysPreferExplicitNewOverLegacy() { + XoopsSettingsState s = XoopsSettingsState.migrateForTest("4.0", "2.5"); + assertEquals("4.0", s.coreVersion); + } + + @Test + public void neitherKeyDefaultsToAuto() { + XoopsSettingsState s = XoopsSettingsState.migrateForTest(null, null); + assertEquals("Auto", s.coreVersion); + assertNull(s.coreProfile); + } + + @Test + public void blankCoreVersionFallsBackToLegacy() { + XoopsSettingsState s = XoopsSettingsState.migrateForTest(" ", "2.5"); + assertEquals("2.5", s.coreVersion); + } +} From 2502e6be9c90d5eafc8ab014d82b6d8c6bc21893 Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 21 Sep 2026 02:29:08 -0400 Subject: [PATCH 03/20] fix: reject xor early exits, cap constant index, normalize template keys Second bot round on the Alpha 3 review PR. - An early-exit condition containing xor is not a dominating isResultSet guard: !isResultSet($r) xor $x can be false while $r is not a result set. - The language-constant cache enforces MAX_CONSTANTS before adding a new distinct name instead of after. - Scanner template check accepts manifest paths spelled from the module root (templates/foo.tpl, blocks/foo.tpl) as well as bare names, so a registration that passes the existence check is not reported as unregistered; the template walk calls checkCanceled like the other walks. --- CHANGELOG.md | 1 + .../XoopsLanguageConstantsCache.java | 6 +++--- .../XoopsResultSetGuardInspection.java | 14 +++++++++++-- .../support/scanner/XoopsProjectScanner.java | 12 ++++++++++- .../XoopsResultSetGuardInspectionTest.java | 13 ++++++++++++ .../scanner/XoopsProjectScannerTest.java | 20 +++++++++++++++++++ 6 files changed, 60 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db48e9a..6afa3ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project uses [Semantic Versioning](https://semver.org/) with pre-releas - Language-constant index keeps original spelling; resolution is exact-case (name recognition stays case-insensitive). Only a `$smarty.const.` prefix is stripped. - isResultSet reassignment ends at `;`, `,`, an unmatched closer, or `or`/`and`/`xor`; `?:`, `??`, `||`, `&&` keep the fetch inside the assignment. - ROOT_PATH guard is still required for includes that start with HTML (quick-fix declines without a file-leading open tag); stub detection ignores `;` inside quotes. +- `xor` in an early-exit condition is not a dominating isResultSet guard; language-constant cap is enforced before the 5001st name; scanner accepts `templates/…` and `blocks/…` manifest spellings and honours Cancel while walking template trees. ## [1.0.0-alpha.3] — 1.0.0 Alpha 3 — 2026-09-02 diff --git a/src/main/java/org/xoops/support/completion/XoopsLanguageConstantsCache.java b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantsCache.java index 16f9010..96fd012 100644 --- a/src/main/java/org/xoops/support/completion/XoopsLanguageConstantsCache.java +++ b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantsCache.java @@ -181,11 +181,11 @@ public void dispose() { continue; } for (XoopsLanguageConstantParser.Occurrence occ : XoopsLanguageConstantParser.parse(psi.getText())) { - names.add(occ.name()); - defs.computeIfAbsent(occ.name(), k -> new ArrayList<>()).add(new Def(vf, occ.offset())); - if (names.size() > MAX_CONSTANTS) { + if (!names.contains(occ.name()) && names.size() >= MAX_CONSTANTS) { return new Index(Collections.unmodifiableSet(names), Map.copyOf(defs)); } + names.add(occ.name()); + defs.computeIfAbsent(occ.name(), k -> new ArrayList<>()).add(new Def(vf, occ.offset())); } } return new Index(Collections.unmodifiableSet(names), Map.copyOf(defs)); diff --git a/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java b/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java index 50301f2..7436e2e 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java +++ b/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java @@ -437,6 +437,15 @@ private static boolean hasBoolOpAnywhere(@NotNull String cond, boolean orOp) { return false; } + private static boolean hasXorAnywhere(@NotNull String cond) { + for (int i = 0; i < cond.length(); i++) { + if (isWordAt(cond, i, "xor")) { + return true; + } + } + return false; + } + private static boolean isWordAt(@NotNull String s, int i, @NotNull String word) { int n = word.length(); if (i + n > s.length()) { @@ -477,8 +486,9 @@ private static boolean isSafeEarlyExitCondition(@NotNull String cond, @NotNull S if (!conditionNegatesIsResultSet(cond, resultVar)) { return false; } - // Reject AND-paths at any depth that make the exit conditional on other predicates. - return !hasBoolOpAnywhere(cond, false); + // Reject AND-paths at any depth that make the exit conditional on other predicates, + // and xor, which can be false while !isResultSet($var) is true. + return !hasBoolOpAnywhere(cond, false) && !hasXorAnywhere(cond); } private static boolean isInsidePositiveIsResultSetGuard( diff --git a/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java b/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java index b66e719..5b0ebbf 100644 --- a/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java +++ b/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java @@ -317,7 +317,15 @@ static void checkRegisteredTemplates(Path moduleRoot, List finding Matcher matcher = REGISTERED_TEMPLATE.matcher(content); while (matcher.find()) { String template = matcher.group(1).replace('\\', '/'); - registered.add(template.toLowerCase(Locale.ROOT)); + String key = template.toLowerCase(Locale.ROOT); + registered.add(key); + // A manifest may spell the path from the module root; the walk keys are + // relative to templates/ or blocks/, so accept both spellings. + if (key.startsWith("templates/")) { + registered.add(key.substring("templates/".length())); + } else if (key.startsWith("blocks/")) { + registered.add(key.substring("blocks/".length())); + } boolean exists = Files.isRegularFile(moduleRoot.resolve("templates").resolve(template)) || Files.isRegularFile(moduleRoot.resolve("blocks").resolve(template)) || Files.isRegularFile(moduleRoot.resolve(template)); @@ -343,10 +351,12 @@ private static void addUnregisteredTemplates( if (!Files.isDirectory(directory)) { return; } + int[] seen = {0}; try (Stream paths = Files.walk(directory, 6)) { paths.filter(Files::isRegularFile) .filter(p -> p.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".tpl")) .forEach(path -> { + checkCanceledEvery(seen); String relative = directory.relativize(path).toString().replace('\\', '/'); if (!registered.contains(relative.toLowerCase(Locale.ROOT))) { findings.add(new XoopsFinding( diff --git a/src/test/java/org/xoops/support/inspections/XoopsResultSetGuardInspectionTest.java b/src/test/java/org/xoops/support/inspections/XoopsResultSetGuardInspectionTest.java index 1b61e69..6c3c244 100644 --- a/src/test/java/org/xoops/support/inspections/XoopsResultSetGuardInspectionTest.java +++ b/src/test/java/org/xoops/support/inspections/XoopsResultSetGuardInspectionTest.java @@ -143,6 +143,19 @@ public void tightBindingOperatorsKeepFetchInsideAssignment() { } } + @Test + public void xorEarlyExitIsNotADominatingGuard() { + String php = """ + query($sql); + if (!$db->isResultSet($result) xor $fallback) { + return; + } + $row = $db->fetchArray($result); + """; + assertEquals(1, XoopsResultSetGuardInspection.unguardedFetchOffsets(php).size()); + } + @Test public void commentContainingIsResultSetDoesNotGuard() { String php = """ diff --git a/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java b/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java index 111f61b..4c5352f 100644 --- a/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java +++ b/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java @@ -69,6 +69,26 @@ public void inverseTemplateScan() throws Exception { } } + @Test + public void moduleRootRelativeRegistrationMatchesWalkKey() throws Exception { + Path moduleRoot = Files.createTempDirectory("xoops-mod"); + try { + Files.writeString(moduleRoot.resolve("xoops_version.php"), """ + 'templates/rooted.tpl', 'description' => '']; + """); + Path templates = moduleRoot.resolve("templates"); + Files.createDirectories(templates); + Files.writeString(templates.resolve("rooted.tpl"), "<{$x}>"); + + List findings = new ArrayList<>(); + XoopsProjectScanner.checkRegisteredTemplates(moduleRoot, findings); + assertTrue(findings.isEmpty()); + } finally { + deleteRecursively(moduleRoot); + } + } + @Test public void missingRegisteredTemplateScan() throws Exception { Path moduleRoot = Files.createTempDirectory("xoops-mod"); From 2cd3f60b077a567686bb337f00bb2cb64f0091e7 Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 21 Sep 2026 02:50:49 -0400 Subject: [PATCH 04/20] fix: accept module-root template spellings in the unregistered-template inspection Same rule as XoopsProjectScanner.checkRegisteredTemplates(): a manifest entry spelled templates/foo.tpl or blocks/bar.tpl also registers the name relative to that directory, so the inspection does not flag the file or offer a duplicate registration quick-fix. --- CHANGELOG.md | 2 +- .../XoopsUnregisteredTemplateInspection.java | 10 +++++++++- .../XoopsUnregisteredTemplateInspectionTest.java | 13 +++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6afa3ce..58b0d81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ and this project uses [Semantic Versioning](https://semver.org/) with pre-releas - Language-constant index keeps original spelling; resolution is exact-case (name recognition stays case-insensitive). Only a `$smarty.const.` prefix is stripped. - isResultSet reassignment ends at `;`, `,`, an unmatched closer, or `or`/`and`/`xor`; `?:`, `??`, `||`, `&&` keep the fetch inside the assignment. - ROOT_PATH guard is still required for includes that start with HTML (quick-fix declines without a file-leading open tag); stub detection ignores `;` inside quotes. -- `xor` in an early-exit condition is not a dominating isResultSet guard; language-constant cap is enforced before the 5001st name; scanner accepts `templates/…` and `blocks/…` manifest spellings and honours Cancel while walking template trees. +- `xor` in an early-exit condition is not a dominating isResultSet guard; language-constant cap is enforced before the 5001st name; scanner accepts `templates/…` and `blocks/…` manifest spellings and honours Cancel while walking template trees; the unregistered-template inspection applies the same manifest-spelling rule. ## [1.0.0-alpha.3] — 1.0.0 Alpha 3 — 2026-09-02 diff --git a/src/main/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspection.java b/src/main/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspection.java index 8657ab2..470fb07 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspection.java +++ b/src/main/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspection.java @@ -80,7 +80,15 @@ public void visitFile(@NotNull PsiFile file) { Set out = new LinkedHashSet<>(); Matcher m = REGISTERED_TEMPLATE.matcher(PhpTextUtil.maskCommentsOnly(manifestText)); while (m.find()) { - out.add(m.group(1).replace('\\', '/').toLowerCase(Locale.ROOT)); + String key = m.group(1).replace('\\', '/').toLowerCase(Locale.ROOT); + out.add(key); + // Accept module-root spellings too; relativeTemplateName() is relative + // to templates/ or blocks/ (same rule as XoopsProjectScanner). + if (key.startsWith("templates/")) { + out.add(key.substring("templates/".length())); + } else if (key.startsWith("blocks/")) { + out.add(key.substring("blocks/".length())); + } } return out; } diff --git a/src/test/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspectionTest.java b/src/test/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspectionTest.java index 408ade4..fca7523 100644 --- a/src/test/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspectionTest.java +++ b/src/test/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspectionTest.java @@ -25,4 +25,17 @@ public void parsesFileAndTemplateKeys() { assertTrue(names.contains("demo_block.tpl")); assertFalse(names.contains("commented.tpl")); } + + @Test + public void moduleRootPrefixedRegistrationsMatchRelativeNames() { + String manifest = """ + 'templates/rooted.tpl']; + $modversion['templates'][] = ['template' => 'blocks/rooted_block.tpl']; + """; + Set names = XoopsUnregisteredTemplateInspection.registeredTemplates(manifest); + assertTrue(names.contains("rooted.tpl")); + assertTrue(names.contains("rooted_block.tpl")); + assertTrue(names.contains("templates/rooted.tpl")); + } } From 9b5770cbb6498628976fb49ef170e097954948d5 Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 21 Sep 2026 03:00:59 -0400 Subject: [PATCH 05/20] fix: block templates under templates/blocks and assignment-style manifests The Overview on a real 2.7 install reported every block template twice: UNREGISTERED_TEMPLATE for templates/blocks/foo.tpl and MISSING_REGISTERED_TEMPLATE for the 'foo.tpl' entry that registers it. - The scanner walk and the missing-template checks (scanner and inspection) know that block templates live in templates/blocks/ and are registered by bare name. - The manifest regex in all three readers also accepts $modversion['blocks'][1]['template'] = 'foo.tpl' assignment syntax, not only 'template' => 'foo.tpl'. - TUTORIAL: per-inspection explanations (why, example, quick-fix, what is skipped) with inspection IDs, and a walkthrough of the Overview tool window with its screenshot. --- CHANGELOG.md | 2 +- TUTORIAL.md | 232 ++++++++++++++++++ ...psMissingRegisteredTemplateInspection.java | 3 +- .../XoopsUnregisteredTemplateInspection.java | 2 +- .../support/scanner/XoopsProjectScanner.java | 9 +- ...opsUnregisteredTemplateInspectionTest.java | 12 + .../scanner/XoopsProjectScannerTest.java | 20 ++ 7 files changed, 275 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58b0d81..593d8e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ and this project uses [Semantic Versioning](https://semver.org/) with pre-releas - Language-constant index keeps original spelling; resolution is exact-case (name recognition stays case-insensitive). Only a `$smarty.const.` prefix is stripped. - isResultSet reassignment ends at `;`, `,`, an unmatched closer, or `or`/`and`/`xor`; `?:`, `??`, `||`, `&&` keep the fetch inside the assignment. - ROOT_PATH guard is still required for includes that start with HTML (quick-fix declines without a file-leading open tag); stub detection ignores `;` inside quotes. -- `xor` in an early-exit condition is not a dominating isResultSet guard; language-constant cap is enforced before the 5001st name; scanner accepts `templates/…` and `blocks/…` manifest spellings and honours Cancel while walking template trees; the unregistered-template inspection applies the same manifest-spelling rule. +- `xor` in an early-exit condition is not a dominating isResultSet guard; language-constant cap is enforced before the 5001st name; scanner accepts `templates/…` and `blocks/…` manifest spellings and honours Cancel while walking template trees; the unregistered-template inspection applies the same manifest-spelling rule. A block template at `templates/blocks/foo.tpl` registered as `foo.tpl` (the XOOPS convention) is neither "unregistered" nor "missing"; `$modversion['blocks'][1]['template'] = '…'` assignment syntax is read as a registration alongside `'template' => '…'`. ## [1.0.0-alpha.3] — 1.0.0 Alpha 3 — 2026-09-02 diff --git a/TUTORIAL.md b/TUTORIAL.md index 5197f75..ceded87 100644 --- a/TUTORIAL.md +++ b/TUTORIAL.md @@ -30,6 +30,38 @@ Then **Install Plugin from Disk…** → `build/distributions/xoops-support-*.zi 2. **Refresh** (or **Tools → XOOPS Support → Refresh XOOPS Overview**) 3. Click findings to open files. +![](https://plugins.jetbrains.com/files/33478/screenshot_200ef8b4-f28f-4bbd-95f3-3f310b27fed7) + +**Reading the Overview.** The header line gives the totals (here 22 modules, 316 findings) and the detected core line (**XOOPS 2.7.x**, from the Core Version setting or auto-detection) with the web root the scan used. + +The **Modules** table lists every directory under `modules/` that has a manifest, one row per module: + +| Column | Meaning | +| --- | --- | +| Manifest | `xoops_version.php` (legacy), `module.json` (4.0), or `hybrid` when both exist | +| TPL | `.tpl` files under `templates/` | +| Lang | `.php` files under `language/` (all locales) | +| Pre | `.php` files under `preloads/` | +| Cls | `.php` files under `class/` and `src/` together | + +The counts are a quick health read: **TPL 0** means the module renders nothing of its own (fine for a library module such as `protector` or `xwhoops`), **Lang 3** next to **Lang 445** shows how many locales ship, and an unexpectedly large **Cls** usually means a vendored library sits under `class/` or `src/`. + +**Findings** are listed per module in the same order as the table. Each line is a kind, a message and a `file:line` link that opens the editor at that spot. The kinds map onto the section 4 inspections: + +| Finding | Inspection | +| --- | --- | +| `MUTATING_QUERY` | 4.6 `XoopsQueryExec` | +| `DEPRECATED_QUERY_F`, `DEPRECATED_QUOTE_STRING` | 4.1 `XoopsDeprecatedDbApi` | +| `RAW_REQUEST` | 4.7 `XoopsSuperglobal`, `$_REQUEST` only; keyed `$_GET` / `$_POST` are left to the editor | +| `MISSING_REGISTERED_TEMPLATE` | 4.8 `XoopsMissingRegisteredTemplate` | +| `UNREGISTERED_TEMPLATE` | 4.9 `XoopsUnregisteredTemplate` | +| `WRONG_SMARTY_DELIMITER` | 4.10 `XoopsWrongSmartyDelimiter` | +| `SCAN_ERROR` | a file or directory the scanner could not read | + +The scanner reports the **first** occurrence per file for the code-pattern kinds, so one `RAW_REQUEST` line can stand for several uses in that file; the editor inspection marks each one. The guard and `isResultSet` rules run only in the editor, where they have the PSI they need. + +Use the Overview as the whole-site view: triage a large install or a module you have just imported, then open a file and let Alt+Enter do the individual fixes. The scan runs as a background task and honours **Cancel**. + ## 4. Inspections + Alt+Enter 1. Copy `test-fixtures/bad_module_sample.php` under `htdocs/modules/_xoops_demo/`. @@ -38,6 +70,206 @@ Then **Install Plugin from Disk…** → `build/distributions/xoops-support-*.zi 4. For Smarty: a `.tpl` with bare `{if …}` should offer delimiter conversion. 5. Goffy / wgSimpleAcc shapes: `test-fixtures/wgsimpleacc_shapes.php` (namespaced `die` guard + `while (list = fetchRow)`) and `test-fixtures/index_404_stub.php` (must **not** warn). +All inspections live under **Settings → Editor → Inspections → XOOPS**. Each can be switched off or downgraded there, and the Inspection ID below is what you put in a `@noinspection` comment or an `.idea/inspectionProfiles` file. Every inspection is silent in `vendor/`, `templates_c/`, `cache/` and `node_modules/`. + +### 4.1 Deprecated database API — `XoopsDeprecatedDbApi` + +Flags `queryF()` and `quoteString()`. Use `query()` / `exec()` and `quote()` instead. + +**Why.** `queryF()` was the "force" variant that skipped the write block on `query()`. Since XOOPS 2.5.12 the split is explicit: `query()` refuses mutating SQL and `exec()` is the only sanctioned write path, so `queryF()` no longer has a job. `quoteString()` is a plain alias of `quote()` kept for backward compatibility and scheduled for removal. + +```php +// before +$db->queryF('UPDATE ' . $db->prefix('news') . ' SET hits = hits + 1'); +$name = $db->quoteString($name); + +// after +$db->exec('UPDATE ' . $db->prefix('news') . ' SET hits = hits + 1'); +$name = $db->quote($name); +``` + +**Quick-fixes.** `quoteString()` is renamed to `quote()`. For `queryF()` the inspection reads the first string argument to decide what to offer: + +- SQL starting with `SELECT` / `SHOW` / `DESCRIBE` / `EXPLAIN`: rename to `query()` only. +- SQL starting with `INSERT` / `UPDATE` / `DELETE` / `REPLACE` / `TRUNCATE` / `ALTER` / `DROP` / `CREATE`: `exec()` is offered first, `query()` second. +- SQL that cannot be read (a variable, a concatenation): both renames are offered; pick by intent. + +`exec()` takes one argument, so the `exec()` rename is withheld while the call still passes `$limit` / `$start`. Drop those first, then re-run the fix. + +### 4.2 Deprecated `XOBJ_DTYPE_UNICODE_*` — `XoopsDeprecatedUnicodeDtype` + +`XOBJ_DTYPE_UNICODE_*` constants were deprecated in XOOPS 2.7.3 (core issue #164) and are scheduled for removal in 4.0. The quick-fix renames the constant to its non-UNICODE successor (`XOBJ_DTYPE_TXTBOX`, `TXTAREA`, `URL`, `EMAIL`, `ARRAY`, `OTHER`). Value migration stays a core concern and is not performed here. Silent when the project Core Version is set to 2.5. + +**Why.** The `UNICODE_*` types date from the pre-UTF-8 era when a field had to declare that it might hold multibyte text. Every supported core runs on `utf8mb4`, so the distinction is dead weight: the sanitizer treats `XOBJ_DTYPE_UNICODE_TXTBOX` and `XOBJ_DTYPE_TXTBOX` identically. Renaming is safe at the `initVar()` site because it changes only which constant name is looked up, not how the stored value is read or written. + +```php +// before +$this->initVar('title', XOBJ_DTYPE_UNICODE_TXTBOX, null, true, 255); + +// after +$this->initVar('title', XOBJ_DTYPE_TXTBOX, null, true, 255); +``` + +**Core Version gate.** The 2.5 LTS line never deprecated these constants, so a module that must keep running on 2.5 gets no warning. Set Core Version to **2.5** in Settings for that project; **Auto**, **2.7** and **4.0** all report. + +### 4.3 `isResultSet` guard — `XoopsResultSetGuard` + +Warns when `fetchArray` / `fetchRow` / `fetchBoth` appears without a dominating `isResultSet` check. Prefer the two-part guard: + +```php +$result = $db->query($sql); +if (!$db->isResultSet($result) || !$result instanceof \mysqli_result) { + throw new \RuntimeException('Database query failed'); +} +while (list($id, $title) = $db->fetchRow($result)) { + // ... +} +``` + +An early-exit guard covers later fetches of the same variable, including `while (list(...) = $db->fetchRow($result))`, until that variable is reassigned. The quick-fix inserts before the enclosing statement using the current PSI (safe to apply several times after Inspect Code). + +**Why two parts.** `query()` returns `false` on failure, and `fetchArray(false)` is a fatal `TypeError` on PHP 8. `isResultSet()` is the XOOPS check; the `instanceof \mysqli_result` half is for static analysers such as Scrutinizer and PHPStan, which do not know that `isResultSet()` narrows the type. + +**What counts as guarded.** The analysis is textual, so it follows a few explicit rules rather than full control flow: + +- A negative check that throws, returns or exits (`if (!$db->isResultSet($result)) { return; }`) covers every later fetch of `$result` in the same block. It stops covering at a reassignment of `$result`, at a nested `function`, or when the enclosing `}` closes. +- A positive check (`if ($db->isResultSet($result)) { ... }`) covers fetches inside its body, brace-less body included. +- `$result = $db->fetchArray($result)` inside a guarded region stays guarded: the fetch reads the old value. `$result = false or $db->fetchArray($result)` does not, because `or` binds looser than `=` and the assignment completes first. +- `&&`, `and` and `xor` in the guard condition disqualify it. `if (!isResultSet($r) && $x) return;` can fall through while `$r` is still `false`. +- Comments and strings are masked first, so a commented-out guard never counts. + +### 4.4 `include_once` for headers — `XoopsIncludeOnceHeader` + +XOOPS module entry points should load `header.php` / `footer.php` with `include_once` (not bare `include`). + +**Why.** `header.php` starts the theme, opens the output buffer and pulls in `$xoopsTpl`; `footer.php` renders and flushes. Including either twice, which happens easily once a page delegates to a shared `include/` file, produces duplicate headers or a blank page. `include_once` makes the second include a no-op. The rule applies to the core files and to a module's own `header.php` / `footer.php`, with or without the `XOOPS_ROOT_PATH .` prefix. + +```php +// before +include XOOPS_ROOT_PATH . '/header.php'; +include __DIR__ . '/footer.php'; + +// after +include_once XOOPS_ROOT_PATH . '/header.php'; +include_once __DIR__ . '/footer.php'; +``` + +**Quick-fix** rewrites the keyword in place. `require` / `require_once` are not touched; only a bare `include` of a `header.php` / `footer.php` path is reported. + +### 4.5 Direct-access guard — `XoopsRootPathGuard` + +Reports include-only PHP files (classes, preloads, includes, blocks) that lack a terminating `defined('XOOPS_ROOT_PATH') || exit/die(...)` guard. The guard must be the first executable statement after `query()`. Use `exec()` for mutations (XOOPS 2.5.12+ convention). + +**Why.** Since 2.5.12 `query()` inspects the statement and blocks writes on a GET request, logging `query() called with a mutating statement; use exec()`. Code that worked on 2.5.11 silently stops writing on upgrade, and the only symptom is a log line. Splitting reads and writes at the call site also makes a future prepared-statement layer possible, because `exec()` never needs `$limit` / `$start`. + +```php +// before +$db->query('DELETE FROM ' . $db->prefix('news') . ' WHERE storyid = ' . $id); + +// after +$db->exec('DELETE FROM ' . $db->prefix('news') . ' WHERE storyid = ' . $id); +``` + +**Quick-fix** renames `query` to `exec` when the call has a single argument. With `$limit` / `$start` present the problem is still reported, but the fix is withheld: `exec()` takes one argument, so the extra ones must be removed by hand first. SQL held in a variable is not inspected; only a string literal as the first argument is read. + +### 4.7 Raw superglobals — `XoopsSuperglobal` + +Flags raw `$_GET`, `$_POST`, `$_REQUEST`, and `$_COOKIE` in module and XMF code. Prefer `\Xmf\Request`. + +**Why.** `\Xmf\Request` does the type coercion, default handling and filtering that hand-written `isset($_POST['x']) ? (int) $_POST['x'] : 0` tends to get subtly wrong, and it makes the input source explicit. `$_REQUEST` merges GET, POST and COOKIE in `php.ini`-dependent order, so a cookie can override a form field; that is why the fix always names a source and never offers `$_REQUEST` as one. + +```php +// before +$op = $_GET['op']; +$name = $_POST['name']; + +// after +$op = \Xmf\Request::getString('op', '', 'GET'); +$name = \Xmf\Request::getString('name', '', 'POST'); +``` + +**Quick-fix scope.** Only a **keyed** read with a string-literal key (`$_GET['op']`, `$_POST['name']`, `$_COOKIE['sid']`) gets an Alt+Enter replacement, and it always uses `getString`. Change it to `getInt`, `getBool`, `getArray` or `getCmd` afterwards when the value is not free text. A bare `$_POST` (passed to a function, iterated, or used with a variable key) and any use of `$_REQUEST` are warnings only, because the right method and source cannot be inferred. + +### 4.8 Missing registered template — `XoopsMissingRegisteredTemplate` + +A template listed in `xoops_version.php` (`file` / `template` key) was not found under the module `templates/` (or `blocks/`) directory. + +**Why.** On install and update XOOPS reads `$modversion['templates']` and copies each listed file into the `tplfile` table. A missing file is skipped without an error, so the page or block later fails with a Smarty "unable to read resource" message that names a template you registered but never created, or that was renamed on disk without the manifest following. + +```php +$modversion['templates'][] = [ + 'file' => 'demo_index.tpl', // must exist as templates/demo_index.tpl + 'description' => 'Index page', +]; +``` + +**Quick-fix** creates the file under `templates/`, keeping any sub-path in the name (`blocks/demo_block.tpl` becomes `templates/blocks/demo_block.tpl`), with a Smarty comment `<{* name *}>` as placeholder so the manifest and disk agree; fill in the markup afterwards. The inspection runs on `xoops_version.php` and is the inverse of 4.9. + +### 4.9 Unregistered template — `XoopsUnregisteredTemplate` + +A `.tpl` file under the module `templates/` or `blocks/` directory is not listed in `xoops_version.php`, so Smarty cannot `display()` it as a registered module template. Theme overrides under `themes/` are ignored. The quick-fix appends a `$modversion['templates'][]` entry to the manifest. + +**Why.** The `db:` template resource resolves through the `tplfile` table, and only registered templates get a row. An unregistered `.tpl` renders fine from the file system on a stock template set during development, then breaks on a site whose template set was imported, or on the first module update that resyncs templates. Registering it at creation time removes the surprise. + +**Matching.** A manifest entry may be spelled relative to `templates/` (`demo_index.tpl`, the XOOPS convention) or from the module root (`templates/demo_index.tpl`); both are accepted. Names are compared case-insensitively. + +**Quick-fix** appends this at the end of the manifest (before a trailing `?>` if there is one), unless the name is already quoted somewhere in the file: + +```php +$modversion['templates'][] = [ + 'file' => 'demo_orphan.tpl', + 'description' => '', +]; +``` + +Files under `themes/` and `templates_c/` are never reported: a theme override is not a module template, and compiled templates are generated. + +### 4.10 Wrong Smarty delimiters — `XoopsWrongSmartyDelimiter` + +XOOPS Smarty uses `<{` and `}>` delimiters. Bare `{if}` / `{$var}` tags are usually a mistake and will not render. + +**Why.** XOOPS configures Smarty with `<{` `}>` so that plain braces in CSS, JavaScript and JSON inside a template are left alone. A tag written in stock Smarty syntax is not an error: it is passed to the browser as literal text, so the symptom is `{$title}` appearing on the page, or an `{if}` block that always shows both branches. + +```smarty + +{if $items}
      {foreach $items as $item}
    • {$item.title}
    • {/foreach}
    {/if} + + +<{if $items}>
      <{foreach $items as $item}>
    • <{$item.title}>
    • <{/foreach}>
    <{/if}> +``` + +**What is matched.** A brace immediately followed by `$` or by one of the common tag keywords (`if`, `/if`, `foreach`, `/foreach`, `include`, `assign`, `block`, `literal`) up to the closing brace on the same line. Tags already written as `<{ … }>` are skipped, and so are braces that do not look like a tag, which keeps inline CSS and JavaScript quiet. + +**Quick-fix** wraps the tag in `<{ }>`. Apply it per tag, or run **Code → Inspect Code** on the `templates/` folder and use **Apply fix** on the group to convert a whole template. + ## 5. Live templates In a PHP file, type the abbreviation and press **Tab**: diff --git a/src/main/java/org/xoops/support/inspections/XoopsMissingRegisteredTemplateInspection.java b/src/main/java/org/xoops/support/inspections/XoopsMissingRegisteredTemplateInspection.java index a5aacdf..8135d1e 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsMissingRegisteredTemplateInspection.java +++ b/src/main/java/org/xoops/support/inspections/XoopsMissingRegisteredTemplateInspection.java @@ -18,7 +18,7 @@ public final class XoopsMissingRegisteredTemplateInspection extends LocalInspectionTool { private static final Pattern REGISTERED_TEMPLATE = Pattern.compile( - "(?is)['\"](?:file|template)['\"]\\s*=>\\s*['\"]([^'\"]+\\.tpl)['\"]" + "(?is)['\"](?:file|template)['\"]\\s*\\]?\\s*=>?\\s*['\"]([^'\"]+\\.tpl)['\"]" ); @Override @@ -43,6 +43,7 @@ public void visitFile(@NotNull PsiFile file) { while (m.find()) { String template = m.group(1).replace('\\', '/'); boolean exists = childExists(moduleRoot, "templates/" + template) + || childExists(moduleRoot, "templates/blocks/" + template) || childExists(moduleRoot, "blocks/" + template) || childExists(moduleRoot, template); if (!exists) { diff --git a/src/main/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspection.java b/src/main/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspection.java index 470fb07..f2e5125 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspection.java +++ b/src/main/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspection.java @@ -24,7 +24,7 @@ public final class XoopsUnregisteredTemplateInspection extends LocalInspectionTool { private static final Pattern REGISTERED_TEMPLATE = Pattern.compile( - "(?is)['\"](?:file|template)['\"]\\s*=>\\s*['\"]([^'\"]+\\.tpl)['\"]" + "(?is)['\"](?:file|template)['\"]\\s*\\]?\\s*=>?\\s*['\"]([^'\"]+\\.tpl)['\"]" ); @Override diff --git a/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java b/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java index 5b0ebbf..aed8917 100644 --- a/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java +++ b/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java @@ -39,7 +39,7 @@ public final class XoopsProjectScanner { static final Pattern VERSION_27 = Pattern.compile("(?i)(?:XOOPS[ _-]?)?2\\.7(?:[^0-9]|$)"); static final Pattern VERSION_40 = Pattern.compile("(?i)(?:XOOPS[ _-]?)?4\\.0(?:[^0-9]|$)"); static final Pattern REGISTERED_TEMPLATE = Pattern.compile( - "(?is)['\"](?:file|template)['\"]\\s*=>\\s*['\"]([^'\"]+\\.tpl)['\"]" + "(?is)['\"](?:file|template)['\"]\\s*\\]?\\s*=>?\\s*['\"]([^'\"]+\\.tpl)['\"]" ); private static final Pattern MANIFEST_DIRNAME = Pattern.compile( "(?is)(?:\\[['\"]dirname['\"]]\\s*=|['\"]dirname['\"]\\s*=>)\\s*['\"]([a-z0-9_-]+)['\"]" @@ -327,6 +327,7 @@ static void checkRegisteredTemplates(Path moduleRoot, List finding registered.add(key.substring("blocks/".length())); } boolean exists = Files.isRegularFile(moduleRoot.resolve("templates").resolve(template)) + || Files.isRegularFile(moduleRoot.resolve("templates/blocks").resolve(template)) || Files.isRegularFile(moduleRoot.resolve("blocks").resolve(template)) || Files.isRegularFile(moduleRoot.resolve(template)); if (!exists) { @@ -358,7 +359,11 @@ private static void addUnregisteredTemplates( .forEach(path -> { checkCanceledEvery(seen); String relative = directory.relativize(path).toString().replace('\\', '/'); - if (!registered.contains(relative.toLowerCase(Locale.ROOT))) { + String key = relative.toLowerCase(Locale.ROOT); + // templates/blocks/foo.tpl is registered as 'foo.tpl' by convention. + boolean listed = registered.contains(key) + || (key.startsWith("blocks/") && registered.contains(key.substring("blocks/".length()))); + if (!listed) { findings.add(new XoopsFinding( "UNREGISTERED_TEMPLATE", path, diff --git a/src/test/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspectionTest.java b/src/test/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspectionTest.java index fca7523..10de607 100644 --- a/src/test/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspectionTest.java +++ b/src/test/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspectionTest.java @@ -26,6 +26,18 @@ public void parsesFileAndTemplateKeys() { assertFalse(names.contains("commented.tpl")); } + @Test + public void assignmentSyntaxRegistersTemplatesToo() { + String manifest = """ + names = XoopsUnregisteredTemplateInspection.registeredTemplates(manifest); + assertTrue(names.contains("demo_block.tpl")); + assertTrue(names.contains("demo_list.tpl")); + } + @Test public void moduleRootPrefixedRegistrationsMatchRelativeNames() { String manifest = """ diff --git a/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java b/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java index 4c5352f..982ac98 100644 --- a/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java +++ b/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java @@ -89,6 +89,26 @@ public void moduleRootRelativeRegistrationMatchesWalkKey() throws Exception { } } + @Test + public void blockTemplateUnderTemplatesBlocksIsRegisteredByBareName() throws Exception { + Path moduleRoot = Files.createTempDirectory("xoops-mod"); + try { + Files.writeString(moduleRoot.resolve("xoops_version.php"), """ + "); + + List findings = new ArrayList<>(); + XoopsProjectScanner.checkRegisteredTemplates(moduleRoot, findings); + assertTrue(findings.toString(), findings.isEmpty()); + } finally { + deleteRecursively(moduleRoot); + } + } + @Test public void missingRegisteredTemplateScan() throws Exception { Path moduleRoot = Files.createTempDirectory("xoops-mod"); From d575f8d4e9b817d86f6ce033a7ba205adfd60cc7 Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 21 Sep 2026 03:26:59 -0400 Subject: [PATCH 06/20] fix: xor in positive guards, commented manifest entries, scan cancel timing Third bot round on the Alpha 3 review PR. - A positive isResultSet(...) xor ... condition does not guard the fetch inside it (true when the result is not a result set and the other operand is). - The scanner reads the manifest through the comment mask, so a commented-out registration is neither "registered" nor "missing". PhpTextUtil is public for that. - The template walk checks cancellation before its filters, and the language-constant cap skips new names instead of ending the scan. - TUTORIAL: alt text on screenshots, guard placement wording, Lang column wording, note that template checks read xoops_version.php. --- CHANGELOG.md | 2 +- TUTORIAL.md | 8 +++++--- .../XoopsLanguageConstantsCache.java | 2 +- .../xoops/support/inspections/PhpTextUtil.java | 4 ++-- .../XoopsResultSetGuardInspection.java | 5 +++-- .../support/scanner/XoopsProjectScanner.java | 8 +++++--- .../XoopsResultSetGuardInspectionTest.java | 12 ++++++++++++ .../scanner/XoopsProjectScannerTest.java | 18 ++++++++++++++++++ 8 files changed, 47 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 593d8e4..bcb0c7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ and this project uses [Semantic Versioning](https://semver.org/) with pre-releas - Language-constant index keeps original spelling; resolution is exact-case (name recognition stays case-insensitive). Only a `$smarty.const.` prefix is stripped. - isResultSet reassignment ends at `;`, `,`, an unmatched closer, or `or`/`and`/`xor`; `?:`, `??`, `||`, `&&` keep the fetch inside the assignment. - ROOT_PATH guard is still required for includes that start with HTML (quick-fix declines without a file-leading open tag); stub detection ignores `;` inside quotes. -- `xor` in an early-exit condition is not a dominating isResultSet guard; language-constant cap is enforced before the 5001st name; scanner accepts `templates/…` and `blocks/…` manifest spellings and honours Cancel while walking template trees; the unregistered-template inspection applies the same manifest-spelling rule. A block template at `templates/blocks/foo.tpl` registered as `foo.tpl` (the XOOPS convention) is neither "unregistered" nor "missing"; `$modversion['blocks'][1]['template'] = '…'` assignment syntax is read as a registration alongside `'template' => '…'`. +- `xor` in an early-exit condition is not a dominating isResultSet guard; language-constant cap is enforced before the 5001st name; scanner accepts `templates/…` and `blocks/…` manifest spellings and honours Cancel while walking template trees; the unregistered-template inspection applies the same manifest-spelling rule. A block template at `templates/blocks/foo.tpl` registered as `foo.tpl` (the XOOPS convention) is neither "unregistered" nor "missing"; `$modversion['blocks'][1]['template'] = '…'` assignment syntax is read as a registration alongside `'template' => '…'`; commented-out entries are ignored by the scanner too. A positive `isResultSet(...) xor …` guard is not a guard. ## [1.0.0-alpha.3] — 1.0.0 Alpha 3 — 2026-09-02 diff --git a/TUTORIAL.md b/TUTORIAL.md index ceded87..6f0401f 100644 --- a/TUTORIAL.md +++ b/TUTORIAL.md @@ -30,7 +30,7 @@ Then **Install Plugin from Disk…** → `build/distributions/xoops-support-*.zi 2. **Refresh** (or **Tools → XOOPS Support → Refresh XOOPS Overview**) 3. Click findings to open files. -![](https://plugins.jetbrains.com/files/33478/screenshot_200ef8b4-f28f-4bbd-95f3-3f310b27fed7) +![XOOPS Support Overview tool window: modules table and findings](https://plugins.jetbrains.com/files/33478/screenshot_200ef8b4-f28f-4bbd-95f3-3f310b27fed7) **Reading the Overview.** The header line gives the totals (here 22 modules, 316 findings) and the detected core line (**XOOPS 2.7.x**, from the Core Version setting or auto-detection) with the web root the scan used. @@ -44,7 +44,7 @@ The **Modules** table lists every directory under `modules/` that has a manifest | Pre | `.php` files under `preloads/` | | Cls | `.php` files under `class/` and `src/` together | -The counts are a quick health read: **TPL 0** means the module renders nothing of its own (fine for a library module such as `protector` or `xwhoops`), **Lang 3** next to **Lang 445** shows how many locales ship, and an unexpectedly large **Cls** usually means a vendored library sits under `class/` or `src/`. +The counts are a quick health read: **TPL 0** means the module renders nothing of its own (fine for a library module such as `protector` or `xwhoops`), **Lang** grows with every shipped locale (each adds its own set of files), and an unexpectedly large **Cls** usually means a vendored library sits under `class/` or `src/`. **Findings** are listed per module in the same order as the table. Each line is a kind, a message and a `file:line` link that opens the editor at that spot. The kinds map onto the section 4 inspections: @@ -158,7 +158,7 @@ include_once __DIR__ . '/footer.php'; ### 4.5 Direct-access guard — `XoopsRootPathGuard` -Reports include-only PHP files (classes, preloads, includes, blocks) that lack a terminating `defined('XOOPS_ROOT_PATH') || exit/die(...)` guard. The guard must be the first executable statement after `` as placeholder so the manifest and disk agree; fill in the markup afterwards. The inspection runs on `xoops_version.php` and is the inverse of 4.9. ### 4.9 Unregistered template — `XoopsUnregisteredTemplate` diff --git a/src/main/java/org/xoops/support/completion/XoopsLanguageConstantsCache.java b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantsCache.java index 96fd012..55f9ee8 100644 --- a/src/main/java/org/xoops/support/completion/XoopsLanguageConstantsCache.java +++ b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantsCache.java @@ -182,7 +182,7 @@ public void dispose() { } for (XoopsLanguageConstantParser.Occurrence occ : XoopsLanguageConstantParser.parse(psi.getText())) { if (!names.contains(occ.name()) && names.size() >= MAX_CONSTANTS) { - return new Index(Collections.unmodifiableSet(names), Map.copyOf(defs)); + continue; // cap new names; definitions of known names are still recorded } names.add(occ.name()); defs.computeIfAbsent(occ.name(), k -> new ArrayList<>()).add(new Def(vf, occ.offset())); diff --git a/src/main/java/org/xoops/support/inspections/PhpTextUtil.java b/src/main/java/org/xoops/support/inspections/PhpTextUtil.java index d78cd12..5ec4c7c 100644 --- a/src/main/java/org/xoops/support/inspections/PhpTextUtil.java +++ b/src/main/java/org/xoops/support/inspections/PhpTextUtil.java @@ -15,7 +15,7 @@ /** * Lightweight helpers for text-based XOOPS inspections. */ -final class PhpTextUtil { +public final class PhpTextUtil { private PhpTextUtil() { } @@ -65,7 +65,7 @@ static boolean looksLikeVendorOrCache(@NotNull PsiFile file) { * Mask comments only (keep string/heredoc contents) so call-site patterns that * need literal tokens still work while ignoring commented-out code. */ - static @NotNull String maskCommentsOnly(@NotNull String text) { + public static @NotNull String maskCommentsOnly(@NotNull String text) { return maskInternal(text, false); } diff --git a/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java b/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java index 7436e2e..5a94ea4 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java +++ b/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java @@ -472,8 +472,9 @@ private static boolean isSafePositiveGuardCondition(@NotNull String cond, @NotNu if (conditionNegatesIsResultSet(cond, resultVar)) { return false; } - // Reject OR-paths at any depth: isResultSet($r) || $fallback / (isResultSet($r) || $x) - return !hasBoolOpAnywhere(cond, true); + // Reject OR-paths at any depth: isResultSet($r) || $fallback / (isResultSet($r) || $x), + // and xor: isResultSet($r) xor $x is true when $r is not a result set and $x is. + return !hasBoolOpAnywhere(cond, true) && !hasXorAnywhere(cond); } /** diff --git a/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java b/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java index aed8917..59054b6 100644 --- a/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java +++ b/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java @@ -2,6 +2,7 @@ import com.intellij.openapi.progress.ProgressManager; import org.jetbrains.annotations.NotNull; +import org.xoops.support.inspections.PhpTextUtil; import java.io.IOException; import java.nio.file.Files; @@ -314,7 +315,8 @@ static void checkRegisteredTemplates(Path moduleRoot, List finding String content = readSmallFile(manifest).orElse(""); Set registered = new LinkedHashSet<>(); if (!content.isEmpty()) { - Matcher matcher = REGISTERED_TEMPLATE.matcher(content); + // Masked copy keeps offsets, so lineAt() on the original content stays right. + Matcher matcher = REGISTERED_TEMPLATE.matcher(PhpTextUtil.maskCommentsOnly(content)); while (matcher.find()) { String template = matcher.group(1).replace('\\', '/'); String key = template.toLowerCase(Locale.ROOT); @@ -354,10 +356,10 @@ private static void addUnregisteredTemplates( } int[] seen = {0}; try (Stream paths = Files.walk(directory, 6)) { - paths.filter(Files::isRegularFile) + paths.peek(p -> checkCanceledEvery(seen)) + .filter(Files::isRegularFile) .filter(p -> p.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".tpl")) .forEach(path -> { - checkCanceledEvery(seen); String relative = directory.relativize(path).toString().replace('\\', '/'); String key = relative.toLowerCase(Locale.ROOT); // templates/blocks/foo.tpl is registered as 'foo.tpl' by convention. diff --git a/src/test/java/org/xoops/support/inspections/XoopsResultSetGuardInspectionTest.java b/src/test/java/org/xoops/support/inspections/XoopsResultSetGuardInspectionTest.java index 6c3c244..03fc4d0 100644 --- a/src/test/java/org/xoops/support/inspections/XoopsResultSetGuardInspectionTest.java +++ b/src/test/java/org/xoops/support/inspections/XoopsResultSetGuardInspectionTest.java @@ -156,6 +156,18 @@ public void xorEarlyExitIsNotADominatingGuard() { assertEquals(1, XoopsResultSetGuardInspection.unguardedFetchOffsets(php).size()); } + @Test + public void xorPositiveGuardIsNotAGuard() { + String php = """ + query($sql); + if ($db->isResultSet($result) xor $fallback) { + $row = $db->fetchArray($result); + } + """; + assertEquals(1, XoopsResultSetGuardInspection.unguardedFetchOffsets(php).size()); + } + @Test public void commentContainingIsResultSetDoesNotGuard() { String php = """ diff --git a/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java b/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java index 982ac98..9b9af5b 100644 --- a/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java +++ b/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java @@ -109,6 +109,24 @@ public void blockTemplateUnderTemplatesBlocksIsRegisteredByBareName() throws Exc } } + @Test + public void commentedManifestEntryIsNotARegistration() throws Exception { + Path moduleRoot = Files.createTempDirectory("xoops-mod"); + try { + Files.writeString(moduleRoot.resolve("xoops_version.php"), """ + 'ghost2.tpl']; */ + """); + Files.createDirectories(moduleRoot.resolve("templates")); + List findings = new ArrayList<>(); + XoopsProjectScanner.checkRegisteredTemplates(moduleRoot, findings); + assertTrue(findings.toString(), findings.isEmpty()); + } finally { + deleteRecursively(moduleRoot); + } + } + @Test public void missingRegisteredTemplateScan() throws Exception { Path moduleRoot = Files.createTempDirectory("xoops-mod"); From adac8803d33d0332b78436d4120596386dced179 Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 21 Sep 2026 05:54:04 -0400 Subject: [PATCH 07/20] fix: keep // and # inside strings out of the comment mask; skip module.json-only template scans - PhpTextUtil.maskCommentsOnly() now steps over single- and double-quoted strings (with escapes) instead of treating a // or # inside them as the start of a comment. A URL in a template description on the same line as 'file' no longer hides that registration from the scanner, the unregistered-template inspection or the ROOT_PATH guard policy. - The scanner returns early when a module has no xoops_version.php, so a module.json-only module is not reported as having every template unregistered. --- CHANGELOG.md | 2 +- .../support/inspections/PhpTextUtil.java | 11 ++++++ .../support/scanner/XoopsProjectScanner.java | 3 ++ ...opsUnregisteredTemplateInspectionTest.java | 12 +++++++ .../scanner/XoopsProjectScannerTest.java | 35 +++++++++++++++++++ 5 files changed, 62 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bcb0c7e..e19e8cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ and this project uses [Semantic Versioning](https://semver.org/) with pre-releas - Language-constant index keeps original spelling; resolution is exact-case (name recognition stays case-insensitive). Only a `$smarty.const.` prefix is stripped. - isResultSet reassignment ends at `;`, `,`, an unmatched closer, or `or`/`and`/`xor`; `?:`, `??`, `||`, `&&` keep the fetch inside the assignment. - ROOT_PATH guard is still required for includes that start with HTML (quick-fix declines without a file-leading open tag); stub detection ignores `;` inside quotes. -- `xor` in an early-exit condition is not a dominating isResultSet guard; language-constant cap is enforced before the 5001st name; scanner accepts `templates/…` and `blocks/…` manifest spellings and honours Cancel while walking template trees; the unregistered-template inspection applies the same manifest-spelling rule. A block template at `templates/blocks/foo.tpl` registered as `foo.tpl` (the XOOPS convention) is neither "unregistered" nor "missing"; `$modversion['blocks'][1]['template'] = '…'` assignment syntax is read as a registration alongside `'template' => '…'`; commented-out entries are ignored by the scanner too. A positive `isResultSet(...) xor …` guard is not a guard. +- `xor` in an early-exit condition is not a dominating isResultSet guard; language-constant cap is enforced before the 5001st name; scanner accepts `templates/…` and `blocks/…` manifest spellings and honours Cancel while walking template trees; the unregistered-template inspection applies the same manifest-spelling rule. A block template at `templates/blocks/foo.tpl` registered as `foo.tpl` (the XOOPS convention) is neither "unregistered" nor "missing"; `$modversion['blocks'][1]['template'] = '…'` assignment syntax is read as a registration alongside `'template' => '…'`; commented-out entries are ignored by the scanner too. A positive `isResultSet(...) xor …` guard is not a guard. The comment mask steps over quoted strings, so `//` or `#` inside a string (a URL in a description) no longer hides the rest of the line from the manifest, guard and template readers. A `module.json`-only module is not scanned for unregistered templates. ## [1.0.0-alpha.3] — 1.0.0 Alpha 3 — 2026-09-02 diff --git a/src/main/java/org/xoops/support/inspections/PhpTextUtil.java b/src/main/java/org/xoops/support/inspections/PhpTextUtil.java index 5ec4c7c..e84a016 100644 --- a/src/main/java/org/xoops/support/inspections/PhpTextUtil.java +++ b/src/main/java/org/xoops/support/inspections/PhpTextUtil.java @@ -179,6 +179,17 @@ static boolean looksLikeVendorOrCache(@NotNull PsiFile file) { continue; } if (!maskStrings) { + // Keep the string, but step over it so // and # inside it are not comments. + if (chars[i] == '\'' || chars[i] == '"') { + char quote = chars[i++]; + while (i < n && chars[i] != quote) { + i += (chars[i] == '\\' && i + 1 < n) ? 2 : 1; + } + if (i < n) { + i++; + } + continue; + } i++; continue; } diff --git a/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java b/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java index 59054b6..94530a1 100644 --- a/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java +++ b/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java @@ -312,6 +312,9 @@ private static void addFirst( static void checkRegisteredTemplates(Path moduleRoot, List findings) { Path manifest = moduleRoot.resolve("xoops_version.php"); + if (!Files.isRegularFile(manifest)) { + return; // module.json-only module: no legacy manifest to compare templates against + } String content = readSmallFile(manifest).orElse(""); Set registered = new LinkedHashSet<>(); if (!content.isEmpty()) { diff --git a/src/test/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspectionTest.java b/src/test/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspectionTest.java index 10de607..c7a38cb 100644 --- a/src/test/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspectionTest.java +++ b/src/test/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspectionTest.java @@ -26,6 +26,18 @@ public void parsesFileAndTemplateKeys() { assertFalse(names.contains("commented.tpl")); } + @Test + public void commentMarkersInsideStringsAreNotComments() { + String manifest = """ + 'see https://xoops.org #1', 'file' => 'listed.tpl']; + // 'file' => 'commented.tpl' + """; + Set names = XoopsUnregisteredTemplateInspection.registeredTemplates(manifest); + assertTrue(names.contains("listed.tpl")); + assertFalse(names.contains("commented.tpl")); + } + @Test public void assignmentSyntaxRegistersTemplatesToo() { String manifest = """ diff --git a/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java b/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java index 9b9af5b..29316ae 100644 --- a/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java +++ b/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java @@ -127,6 +127,41 @@ public void commentedManifestEntryIsNotARegistration() throws Exception { } } + @Test + public void urlInDescriptionDoesNotHideRegistrationOnSameLine() throws Exception { + Path moduleRoot = Files.createTempDirectory("xoops-mod"); + try { + Files.writeString(moduleRoot.resolve("xoops_version.php"), """ + 'see https://xoops.org #1', 'file' => 'listed.tpl']; + """); + Path templates = moduleRoot.resolve("templates"); + Files.createDirectories(templates); + Files.writeString(templates.resolve("listed.tpl"), "<{$x}>"); + List findings = new ArrayList<>(); + XoopsProjectScanner.checkRegisteredTemplates(moduleRoot, findings); + assertTrue(findings.toString(), findings.isEmpty()); + } finally { + deleteRecursively(moduleRoot); + } + } + + @Test + public void moduleJsonOnlyModuleIsNotScannedForTemplates() throws Exception { + Path moduleRoot = Files.createTempDirectory("xoops-mod"); + try { + Files.writeString(moduleRoot.resolve("module.json"), "{\"name\": \"demo\"}"); + Path templates = moduleRoot.resolve("templates"); + Files.createDirectories(templates); + Files.writeString(templates.resolve("demo_index.tpl"), "<{$x}>"); + List findings = new ArrayList<>(); + XoopsProjectScanner.checkRegisteredTemplates(moduleRoot, findings); + assertTrue(findings.toString(), findings.isEmpty()); + } finally { + deleteRecursively(moduleRoot); + } + } + @Test public void missingRegisteredTemplateScan() throws Exception { Path moduleRoot = Files.createTempDirectory("xoops-mod"); From 1f98fef41cf82bd0c80eb687c977cb9f7132d3b9 Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 21 Sep 2026 06:07:04 -0400 Subject: [PATCH 08/20] docs: full XOBJ_DTYPE successor names and guard placement wording in inspection descriptions --- .../XoopsDeprecatedUnicodeDtype.html | 10 +++++++--- .../inspectionDescriptions/XoopsRootPathGuard.html | 8 ++++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/main/resources/inspectionDescriptions/XoopsDeprecatedUnicodeDtype.html b/src/main/resources/inspectionDescriptions/XoopsDeprecatedUnicodeDtype.html index 2e30d4d..4e496ba 100644 --- a/src/main/resources/inspectionDescriptions/XoopsDeprecatedUnicodeDtype.html +++ b/src/main/resources/inspectionDescriptions/XoopsDeprecatedUnicodeDtype.html @@ -7,9 +7,13 @@ XOBJ_DTYPE_UNICODE_* constants were deprecated in XOOPS 2.7.3 (core issue #164) and are scheduled for removal in 4.0.

    -Quick-fix renames the constant to its non-UNICODE successor -(XOBJ_DTYPE_TXTBOX, TXTAREA, URL, -EMAIL, ARRAY, OTHER). +Quick-fix renames the constant to its non-UNICODE successor: +XOBJ_DTYPE_UNICODE_TXTBOX → XOBJ_DTYPE_TXTBOX, +XOBJ_DTYPE_UNICODE_TXTAREA → XOBJ_DTYPE_TXTAREA, +XOBJ_DTYPE_UNICODE_URL → XOBJ_DTYPE_URL, +XOBJ_DTYPE_UNICODE_EMAIL → XOBJ_DTYPE_EMAIL, +XOBJ_DTYPE_UNICODE_ARRAY → XOBJ_DTYPE_ARRAY, +XOBJ_DTYPE_UNICODE_OTHER → XOBJ_DTYPE_OTHER. Value migration stays a core concern and is not performed here.

    Silent when the project Core Version is set to 2.5.

    diff --git a/src/main/resources/inspectionDescriptions/XoopsRootPathGuard.html b/src/main/resources/inspectionDescriptions/XoopsRootPathGuard.html index 805ae40..f129935 100644 --- a/src/main/resources/inspectionDescriptions/XoopsRootPathGuard.html +++ b/src/main/resources/inspectionDescriptions/XoopsRootPathGuard.html @@ -5,10 +5,10 @@ terminating defined('XOOPS_ROOT_PATH') || exit/die(...) guard.

    The guard must be the first executable statement after <?php, -declare, namespace, and use. -die and exit are both accepted. -In namespaced files the quick-fix inserts the guard after the namespace -(never before — that is invalid PHP). +declare and namespace; a use block before or +after it is fine. die and exit are both accepted. +In namespaced files the quick-fix inserts the guard after the namespace and +before the use block (never before namespace, that is invalid PHP).

    Not reported: language files, vendor/cache, tests, xoops_version.php, From 553622ba1fda970f84ecb087c3d4cced6480e22f Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 21 Sep 2026 06:59:38 -0400 Subject: [PATCH 09/20] fix: missing-template inspection read masked strings; tighten define(), guards and quick-fixes - XoopsMissingRegisteredTemplateInspection masked string literals before matching 'file' => 'x.tpl', so it could never report. It now uses the comment-only mask like the scanner and the inverse inspection. - define() must be a real call: mydefine(), $obj->define() and Foo::define() are not indexed; \define() is. - RegisterTemplateQuickFix checks for an existing registration through the manifest parser, so a commented-out entry does not suppress the fix. - A positive isResultSet guard does not cover a fetch inside a closure declared in its body. - The ROOT_PATH quick-fix is attached only when the policy has a safe insertion point; HTML-leading files are still reported without it. - templates/blocks/foo.tpl in a manifest also registers blocks/foo.tpl and foo.tpl, in both the inspection and the scanner. --- CHANGELOG.md | 2 +- .../XoopsLanguageConstantParser.java | 2 +- .../inspections/RegisterTemplateQuickFix.java | 5 ++--- ...opsMissingRegisteredTemplateInspection.java | 3 ++- .../XoopsResultSetGuardInspection.java | 4 +++- .../XoopsRootPathGuardInspection.java | 18 ++++++++++-------- .../inspections/XoopsRootPathGuardPolicy.java | 17 +++++++++++++++++ .../XoopsUnregisteredTemplateInspection.java | 6 ++++-- .../support/scanner/XoopsProjectScanner.java | 6 ++++-- .../XoopsLanguageConstantParserTest.java | 15 +++++++++++++++ .../XoopsResultSetGuardInspectionTest.java | 14 ++++++++++++++ .../XoopsRootPathGuardPolicyTest.java | 4 ++++ ...oopsUnregisteredTemplateInspectionTest.java | 4 ++++ 13 files changed, 81 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e19e8cb..75fd75c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ and this project uses [Semantic Versioning](https://semver.org/) with pre-releas - Language-constant index keeps original spelling; resolution is exact-case (name recognition stays case-insensitive). Only a `$smarty.const.` prefix is stripped. - isResultSet reassignment ends at `;`, `,`, an unmatched closer, or `or`/`and`/`xor`; `?:`, `??`, `||`, `&&` keep the fetch inside the assignment. - ROOT_PATH guard is still required for includes that start with HTML (quick-fix declines without a file-leading open tag); stub detection ignores `;` inside quotes. -- `xor` in an early-exit condition is not a dominating isResultSet guard; language-constant cap is enforced before the 5001st name; scanner accepts `templates/…` and `blocks/…` manifest spellings and honours Cancel while walking template trees; the unregistered-template inspection applies the same manifest-spelling rule. A block template at `templates/blocks/foo.tpl` registered as `foo.tpl` (the XOOPS convention) is neither "unregistered" nor "missing"; `$modversion['blocks'][1]['template'] = '…'` assignment syntax is read as a registration alongside `'template' => '…'`; commented-out entries are ignored by the scanner too. A positive `isResultSet(...) xor …` guard is not a guard. The comment mask steps over quoted strings, so `//` or `#` inside a string (a URL in a description) no longer hides the rest of the line from the manifest, guard and template readers. A `module.json`-only module is not scanned for unregistered templates. +- `xor` in an early-exit condition is not a dominating isResultSet guard; language-constant cap is enforced before the 5001st name; scanner accepts `templates/…` and `blocks/…` manifest spellings and honours Cancel while walking template trees; the unregistered-template inspection applies the same manifest-spelling rule. A block template at `templates/blocks/foo.tpl` registered as `foo.tpl` (the XOOPS convention) is neither "unregistered" nor "missing"; `$modversion['blocks'][1]['template'] = '…'` assignment syntax is read as a registration alongside `'template' => '…'`; commented-out entries are ignored by the scanner too. A positive `isResultSet(...) xor …` guard is not a guard. The comment mask steps over quoted strings, so `//` or `#` inside a string (a URL in a description) no longer hides the rest of the line from the manifest, guard and template readers. A `module.json`-only module is not scanned for unregistered templates. The missing-registered-template inspection reads the manifest through the comment-only mask (it had masked the string literals it needed and never reported). `define()` must be a real call (`mydefine('_MI_…')` is not indexed). The register-template quick-fix ignores commented-out entries. A positive `isResultSet` guard does not cover a fetch inside a closure. The ROOT_PATH quick-fix is attached only where it can insert. ## [1.0.0-alpha.3] — 1.0.0 Alpha 3 — 2026-09-02 diff --git a/src/main/java/org/xoops/support/completion/XoopsLanguageConstantParser.java b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantParser.java index 64516e0..7cffb73 100644 --- a/src/main/java/org/xoops/support/completion/XoopsLanguageConstantParser.java +++ b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantParser.java @@ -16,7 +16,7 @@ public final class XoopsLanguageConstantParser { public static final Pattern DEFINE = Pattern.compile( - "define\\s*\\(\\s*['\"](_(?:MI|AM|MD|CO|MB)_[A-Z0-9_]+)['\"]", + "(?:])\\\\?define\\b\\s*\\(\\s*['\"](_(?:MI|AM|MD|CO|MB)_[A-Z0-9_]+)['\"]", Pattern.CASE_INSENSITIVE ); diff --git a/src/main/java/org/xoops/support/inspections/RegisterTemplateQuickFix.java b/src/main/java/org/xoops/support/inspections/RegisterTemplateQuickFix.java index 8b7cf2c..9498bff 100644 --- a/src/main/java/org/xoops/support/inspections/RegisterTemplateQuickFix.java +++ b/src/main/java/org/xoops/support/inspections/RegisterTemplateQuickFix.java @@ -57,10 +57,9 @@ public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descri return; } String text = document.getText(); - String lowerText = text.toLowerCase(Locale.ROOT); String lowerName = templateName.toLowerCase(Locale.ROOT); - if (lowerText.contains("'" + lowerName + "'") - || lowerText.contains("\"" + lowerName + "\"")) { + // Same parser as the inspection: a commented-out entry is not a registration. + if (XoopsUnregisteredTemplateInspection.registeredTemplates(text).contains(lowerName)) { return; } String entry = "\n$modversion['templates'][] = [\n" diff --git a/src/main/java/org/xoops/support/inspections/XoopsMissingRegisteredTemplateInspection.java b/src/main/java/org/xoops/support/inspections/XoopsMissingRegisteredTemplateInspection.java index 8135d1e..acdc81d 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsMissingRegisteredTemplateInspection.java +++ b/src/main/java/org/xoops/support/inspections/XoopsMissingRegisteredTemplateInspection.java @@ -38,7 +38,8 @@ public void visitFile(@NotNull PsiFile file) { } VirtualFile moduleRoot = vf.getParent(); String text = file.getText(); - String code = PhpTextUtil.maskCommentsAndStrings(text); + // Comments only: the registration key and file name are string literals. + String code = PhpTextUtil.maskCommentsOnly(text); Matcher m = REGISTERED_TEMPLATE.matcher(code); while (m.find()) { String template = m.group(1).replace('\\', '/'); diff --git a/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java b/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java index 5a94ea4..0fdecca 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java +++ b/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java @@ -521,7 +521,9 @@ private static boolean isInsidePositiveIsResultSetGuard( } } if (depth > 0) { - return !assignsResultVar(code, ic.openBrace + 1, fetchOffset, resultVar); + // A closure can run after $var is reassigned, so the guard does not dominate it. + return !assignsResultVar(code, ic.openBrace + 1, fetchOffset, resultVar) + && !hasFunctionKeyword(code, ic.openBrace + 1, fetchOffset); } } else if (fetchOffset > ic.condEnd && fetchOffset < ic.ifEnd) { return !assignsResultVar(code, ic.condEnd + 1, fetchOffset, resultVar); diff --git a/src/main/java/org/xoops/support/inspections/XoopsRootPathGuardInspection.java b/src/main/java/org/xoops/support/inspections/XoopsRootPathGuardInspection.java index 733483e..7bd7b74 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsRootPathGuardInspection.java +++ b/src/main/java/org/xoops/support/inspections/XoopsRootPathGuardInspection.java @@ -60,14 +60,16 @@ public void visitFile(@NotNull PsiFile file) { } } } - holder.registerProblem( - anchor, - "XOOPS: missing direct-access guard - add " - + "defined('XOOPS_ROOT_PATH') || exit('Restricted access'); " - + "as the first statement after \\s*$", "").strip(); } + /** + * True when {@link InsertRootPathGuardQuickFix} has a safe place to insert: a + * file-leading {@code = 0) { + return true; + } + Matcher echo = OPEN_ECHO.matcher(text); + if (echo.find() && isFileLeading(text, echo.start())) { + return true; + } + return !text.contains("= 0; diff --git a/src/main/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspection.java b/src/main/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspection.java index f2e5125..d97da6f 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspection.java +++ b/src/main/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspection.java @@ -85,8 +85,10 @@ public void visitFile(@NotNull PsiFile file) { // Accept module-root spellings too; relativeTemplateName() is relative // to templates/ or blocks/ (same rule as XoopsProjectScanner). if (key.startsWith("templates/")) { - out.add(key.substring("templates/".length())); - } else if (key.startsWith("blocks/")) { + key = key.substring("templates/".length()); + out.add(key); + } + if (key.startsWith("blocks/")) { out.add(key.substring("blocks/".length())); } } diff --git a/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java b/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java index 94530a1..8e0711d 100644 --- a/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java +++ b/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java @@ -327,8 +327,10 @@ static void checkRegisteredTemplates(Path moduleRoot, List finding // A manifest may spell the path from the module root; the walk keys are // relative to templates/ or blocks/, so accept both spellings. if (key.startsWith("templates/")) { - registered.add(key.substring("templates/".length())); - } else if (key.startsWith("blocks/")) { + key = key.substring("templates/".length()); + registered.add(key); + } + if (key.startsWith("blocks/")) { registered.add(key.substring("blocks/".length())); } boolean exists = Files.isRegularFile(moduleRoot.resolve("templates").resolve(template)) diff --git a/src/test/java/org/xoops/support/completion/XoopsLanguageConstantParserTest.java b/src/test/java/org/xoops/support/completion/XoopsLanguageConstantParserTest.java index 6c820d0..9fd6e7a 100644 --- a/src/test/java/org/xoops/support/completion/XoopsLanguageConstantParserTest.java +++ b/src/test/java/org/xoops/support/completion/XoopsLanguageConstantParserTest.java @@ -36,6 +36,21 @@ public void extractNameAcceptsQuotedAndBare() { assertNull(XoopsLanguageConstantParser.extractName("'_XX_FOO'")); } + @Test + public void parseIgnoresEmbeddedFunctionNames() { + String php = """ + define('_MI_NOT_EITHER', 'x'); + \\define('_MI_NAMESPACED', 'y'); + define('_MI_PLAIN', 'z'); + """; + List occ = XoopsLanguageConstantParser.parse(php); + assertEquals(2, occ.size()); + assertEquals("_MI_NAMESPACED", occ.get(0).name()); + assertEquals("_MI_PLAIN", occ.get(1).name()); + } + @Test public void parsePreservesDistinctSpellings() { String php = """ diff --git a/src/test/java/org/xoops/support/inspections/XoopsResultSetGuardInspectionTest.java b/src/test/java/org/xoops/support/inspections/XoopsResultSetGuardInspectionTest.java index 03fc4d0..c3c16ed 100644 --- a/src/test/java/org/xoops/support/inspections/XoopsResultSetGuardInspectionTest.java +++ b/src/test/java/org/xoops/support/inspections/XoopsResultSetGuardInspectionTest.java @@ -168,6 +168,20 @@ public void xorPositiveGuardIsNotAGuard() { assertEquals(1, XoopsResultSetGuardInspection.unguardedFetchOffsets(php).size()); } + @Test + public void closureInsidePositiveGuardIsNotGuarded() { + String php = """ + query($sql); + if ($db->isResultSet($result)) { + $later = function () use ($db, &$result) { + return $db->fetchArray($result); + }; + } + """; + assertEquals(1, XoopsResultSetGuardInspection.unguardedFetchOffsets(php).size()); + } + @Test public void commentContainingIsResultSetDoesNotGuard() { String php = """ diff --git a/src/test/java/org/xoops/support/inspections/XoopsRootPathGuardPolicyTest.java b/src/test/java/org/xoops/support/inspections/XoopsRootPathGuardPolicyTest.java index 80b3b17..b78df1c 100644 --- a/src/test/java/org/xoops/support/inspections/XoopsRootPathGuardPolicyTest.java +++ b/src/test/java/org/xoops/support/inspections/XoopsRootPathGuardPolicyTest.java @@ -94,6 +94,10 @@ public void htmlLeadingIncludeStillRequiresGuardButDeclinesInsert() { source )); assertEquals(-1, XoopsRootPathGuardPolicy.insertOffset(source)); + assertFalse(XoopsRootPathGuardPolicy.canInsertGuard(source)); + assertTrue(XoopsRootPathGuardPolicy.canInsertGuard("\n")); + assertTrue(XoopsRootPathGuardPolicy.canInsertGuard("plain text, no php\n")); } @Test diff --git a/src/test/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspectionTest.java b/src/test/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspectionTest.java index c7a38cb..c3f161a 100644 --- a/src/test/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspectionTest.java +++ b/src/test/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspectionTest.java @@ -61,5 +61,9 @@ public void moduleRootPrefixedRegistrationsMatchRelativeNames() { assertTrue(names.contains("rooted.tpl")); assertTrue(names.contains("rooted_block.tpl")); assertTrue(names.contains("templates/rooted.tpl")); + Set full = XoopsUnregisteredTemplateInspection.registeredTemplates( + " 'templates/blocks/deep_block.tpl'];"); + assertTrue(full.contains("blocks/deep_block.tpl")); + assertTrue(full.contains("deep_block.tpl")); } } From 2d91e675140ab761ae70908ddc1267d3063e6ad5 Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 21 Sep 2026 07:12:07 -0400 Subject: [PATCH 10/20] fix: report an unreadable or oversized manifest instead of flagging every template checkRegisteredTemplates() treated a manifest that could not be read (IO error or above MAX_SOURCE_BYTES) as empty and then reported every .tpl on disk as UNREGISTERED_TEMPLATE. It now adds one SCAN_ERROR finding on xoops_version.php and returns. --- CHANGELOG.md | 2 +- .../support/scanner/XoopsProjectScanner.java | 14 ++++++++++++- .../scanner/XoopsProjectScannerTest.java | 20 +++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75fd75c..ca6c05e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ and this project uses [Semantic Versioning](https://semver.org/) with pre-releas - Language-constant index keeps original spelling; resolution is exact-case (name recognition stays case-insensitive). Only a `$smarty.const.` prefix is stripped. - isResultSet reassignment ends at `;`, `,`, an unmatched closer, or `or`/`and`/`xor`; `?:`, `??`, `||`, `&&` keep the fetch inside the assignment. - ROOT_PATH guard is still required for includes that start with HTML (quick-fix declines without a file-leading open tag); stub detection ignores `;` inside quotes. -- `xor` in an early-exit condition is not a dominating isResultSet guard; language-constant cap is enforced before the 5001st name; scanner accepts `templates/…` and `blocks/…` manifest spellings and honours Cancel while walking template trees; the unregistered-template inspection applies the same manifest-spelling rule. A block template at `templates/blocks/foo.tpl` registered as `foo.tpl` (the XOOPS convention) is neither "unregistered" nor "missing"; `$modversion['blocks'][1]['template'] = '…'` assignment syntax is read as a registration alongside `'template' => '…'`; commented-out entries are ignored by the scanner too. A positive `isResultSet(...) xor …` guard is not a guard. The comment mask steps over quoted strings, so `//` or `#` inside a string (a URL in a description) no longer hides the rest of the line from the manifest, guard and template readers. A `module.json`-only module is not scanned for unregistered templates. The missing-registered-template inspection reads the manifest through the comment-only mask (it had masked the string literals it needed and never reported). `define()` must be a real call (`mydefine('_MI_…')` is not indexed). The register-template quick-fix ignores commented-out entries. A positive `isResultSet` guard does not cover a fetch inside a closure. The ROOT_PATH quick-fix is attached only where it can insert. +- `xor` in an early-exit condition is not a dominating isResultSet guard; language-constant cap is enforced before the 5001st name; scanner accepts `templates/…` and `blocks/…` manifest spellings and honours Cancel while walking template trees; the unregistered-template inspection applies the same manifest-spelling rule. A block template at `templates/blocks/foo.tpl` registered as `foo.tpl` (the XOOPS convention) is neither "unregistered" nor "missing"; `$modversion['blocks'][1]['template'] = '…'` assignment syntax is read as a registration alongside `'template' => '…'`; commented-out entries are ignored by the scanner too. A positive `isResultSet(...) xor …` guard is not a guard. The comment mask steps over quoted strings, so `//` or `#` inside a string (a URL in a description) no longer hides the rest of the line from the manifest, guard and template readers. A `module.json`-only module is not scanned for unregistered templates. The missing-registered-template inspection reads the manifest through the comment-only mask (it had masked the string literals it needed and never reported). `define()` must be a real call (`mydefine('_MI_…')` is not indexed). The register-template quick-fix ignores commented-out entries. A positive `isResultSet` guard does not cover a fetch inside a closure. The ROOT_PATH quick-fix is attached only where it can insert. An unreadable or oversized `xoops_version.php` yields one `SCAN_ERROR` instead of every template being reported unregistered. ## [1.0.0-alpha.3] — 1.0.0 Alpha 3 — 2026-09-02 diff --git a/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java b/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java index 8e0711d..85c997d 100644 --- a/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java +++ b/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java @@ -315,7 +315,19 @@ static void checkRegisteredTemplates(Path moduleRoot, List finding if (!Files.isRegularFile(manifest)) { return; // module.json-only module: no legacy manifest to compare templates against } - String content = readSmallFile(manifest).orElse(""); + Optional read = readSmallFile(manifest); + if (read.isEmpty()) { + // Unreadable or oversized manifest: no data to compare against, so report + // that instead of flagging every template as unregistered. + findings.add(new XoopsFinding( + "SCAN_ERROR", + manifest, + 1, + "xoops_version.php could not be read (unreadable or larger than " + MAX_SOURCE_BYTES + " bytes)" + )); + return; + } + String content = read.get(); Set registered = new LinkedHashSet<>(); if (!content.isEmpty()) { // Masked copy keeps offsets, so lineAt() on the original content stays right. diff --git a/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java b/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java index 29316ae..79a8c86 100644 --- a/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java +++ b/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java @@ -162,6 +162,26 @@ public void moduleJsonOnlyModuleIsNotScannedForTemplates() throws Exception { } } + @Test + public void oversizedManifestReportsScanErrorNotUnregisteredTemplates() throws Exception { + Path moduleRoot = Files.createTempDirectory("xoops-mod"); + try { + byte[] big = new byte[1_500_001]; + java.util.Arrays.fill(big, (byte) ' '); + Files.write(moduleRoot.resolve("xoops_version.php"), big); + Path templates = moduleRoot.resolve("templates"); + Files.createDirectories(templates); + Files.writeString(templates.resolve("demo_index.tpl"), "<{$x}>"); + + List findings = new ArrayList<>(); + XoopsProjectScanner.checkRegisteredTemplates(moduleRoot, findings); + assertEquals(findings.toString(), 1, findings.size()); + assertEquals("SCAN_ERROR", findings.get(0).kind()); + } finally { + deleteRecursively(moduleRoot); + } + } + @Test public void missingRegisteredTemplateScan() throws Exception { Path moduleRoot = Files.createTempDirectory("xoops-mod"); From fb552f65e5f0a5f99f6e461b6a978e6534a6595c Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 21 Sep 2026 07:44:47 -0400 Subject: [PATCH 11/20] fix: do not index commented-out define() calls as language constants XoopsLanguageConstantParser.parse() now runs on the comment-masked source (strings kept, offsets preserved), so a define() inside a // or /* */ comment neither appears in completion nor becomes a Ctrl+B target. Same mask the manifest, guard and template readers use. --- CHANGELOG.md | 2 +- .../completion/XoopsLanguageConstantParser.java | 5 ++++- .../XoopsLanguageConstantParserTest.java | 14 ++++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca6c05e..afeb6df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ and this project uses [Semantic Versioning](https://semver.org/) with pre-releas - Language-constant index keeps original spelling; resolution is exact-case (name recognition stays case-insensitive). Only a `$smarty.const.` prefix is stripped. - isResultSet reassignment ends at `;`, `,`, an unmatched closer, or `or`/`and`/`xor`; `?:`, `??`, `||`, `&&` keep the fetch inside the assignment. - ROOT_PATH guard is still required for includes that start with HTML (quick-fix declines without a file-leading open tag); stub detection ignores `;` inside quotes. -- `xor` in an early-exit condition is not a dominating isResultSet guard; language-constant cap is enforced before the 5001st name; scanner accepts `templates/…` and `blocks/…` manifest spellings and honours Cancel while walking template trees; the unregistered-template inspection applies the same manifest-spelling rule. A block template at `templates/blocks/foo.tpl` registered as `foo.tpl` (the XOOPS convention) is neither "unregistered" nor "missing"; `$modversion['blocks'][1]['template'] = '…'` assignment syntax is read as a registration alongside `'template' => '…'`; commented-out entries are ignored by the scanner too. A positive `isResultSet(...) xor …` guard is not a guard. The comment mask steps over quoted strings, so `//` or `#` inside a string (a URL in a description) no longer hides the rest of the line from the manifest, guard and template readers. A `module.json`-only module is not scanned for unregistered templates. The missing-registered-template inspection reads the manifest through the comment-only mask (it had masked the string literals it needed and never reported). `define()` must be a real call (`mydefine('_MI_…')` is not indexed). The register-template quick-fix ignores commented-out entries. A positive `isResultSet` guard does not cover a fetch inside a closure. The ROOT_PATH quick-fix is attached only where it can insert. An unreadable or oversized `xoops_version.php` yields one `SCAN_ERROR` instead of every template being reported unregistered. +- `xor` in an early-exit condition is not a dominating isResultSet guard; language-constant cap is enforced before the 5001st name; scanner accepts `templates/…` and `blocks/…` manifest spellings and honours Cancel while walking template trees; the unregistered-template inspection applies the same manifest-spelling rule. A block template at `templates/blocks/foo.tpl` registered as `foo.tpl` (the XOOPS convention) is neither "unregistered" nor "missing"; `$modversion['blocks'][1]['template'] = '…'` assignment syntax is read as a registration alongside `'template' => '…'`; commented-out entries are ignored by the scanner too. A positive `isResultSet(...) xor …` guard is not a guard. The comment mask steps over quoted strings, so `//` or `#` inside a string (a URL in a description) no longer hides the rest of the line from the manifest, guard and template readers. A `module.json`-only module is not scanned for unregistered templates. The missing-registered-template inspection reads the manifest through the comment-only mask (it had masked the string literals it needed and never reported). `define()` must be a real call (`mydefine('_MI_…')` is not indexed). The register-template quick-fix ignores commented-out entries. A positive `isResultSet` guard does not cover a fetch inside a closure. The ROOT_PATH quick-fix is attached only where it can insert. An unreadable or oversized `xoops_version.php` yields one `SCAN_ERROR` instead of every template being reported unregistered. Commented-out `define()` lines are not indexed as language constants. ## [1.0.0-alpha.3] — 1.0.0 Alpha 3 — 2026-09-02 diff --git a/src/main/java/org/xoops/support/completion/XoopsLanguageConstantParser.java b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantParser.java index 7cffb73..ecd4231 100644 --- a/src/main/java/org/xoops/support/completion/XoopsLanguageConstantParser.java +++ b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantParser.java @@ -2,6 +2,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.xoops.support.inspections.PhpTextUtil; import java.util.ArrayList; import java.util.List; @@ -32,7 +33,9 @@ private XoopsLanguageConstantParser() { */ public static @NotNull List parse(@NotNull String phpSource) { List out = new ArrayList<>(); - Matcher m = DEFINE.matcher(phpSource); + // Comments masked, strings kept: a commented-out define() is not a definition, + // and the mask preserves offsets so Ctrl+B still lands on the real name. + Matcher m = DEFINE.matcher(PhpTextUtil.maskCommentsOnly(phpSource)); while (m.find()) { out.add(new Occurrence(m.group(1), m.start(1))); } diff --git a/src/test/java/org/xoops/support/completion/XoopsLanguageConstantParserTest.java b/src/test/java/org/xoops/support/completion/XoopsLanguageConstantParserTest.java index 9fd6e7a..98f62b8 100644 --- a/src/test/java/org/xoops/support/completion/XoopsLanguageConstantParserTest.java +++ b/src/test/java/org/xoops/support/completion/XoopsLanguageConstantParserTest.java @@ -36,6 +36,20 @@ public void extractNameAcceptsQuotedAndBare() { assertNull(XoopsLanguageConstantParser.extractName("'_XX_FOO'")); } + @Test + public void parseIgnoresCommentedOutDefines() { + String php = """ + occ = XoopsLanguageConstantParser.parse(php); + assertEquals(1, occ.size()); + assertEquals("_MI_LIVE", occ.get(0).name()); + assertEquals(php.indexOf("_MI_LIVE"), occ.get(0).offset()); + } + @Test public void parseIgnoresEmbeddedFunctionNames() { String php = """ From b62fd390a130c949bb8fe6a3c56ed73d514fcba0 Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 21 Sep 2026 09:41:59 -0400 Subject: [PATCH 12/20] fix: skip heredoc bodies in the comment-only mask; treat the language dir itself as a language path - PhpTextUtil.maskInternal() detects heredoc/nowdoc in both modes and only wipes the body when strings are being masked, so a // or /* inside a literal body can no longer swallow later define() calls or manifest entries. PhpTextUtilTest covers both modes. - isLanguagePath() accepts a path that ends in /language, so a VFS event on the directory itself invalidates the constant cache. --- CHANGELOG.md | 2 +- .../XoopsLanguageConstantParser.java | 2 +- .../support/inspections/PhpTextUtil.java | 39 +++++++++++-------- .../XoopsLanguageConstantParserTest.java | 3 ++ .../support/inspections/PhpTextUtilTest.java | 37 ++++++++++++++++++ 5 files changed, 64 insertions(+), 19 deletions(-) create mode 100644 src/test/java/org/xoops/support/inspections/PhpTextUtilTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index afeb6df..106c4e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ and this project uses [Semantic Versioning](https://semver.org/) with pre-releas - Language-constant index keeps original spelling; resolution is exact-case (name recognition stays case-insensitive). Only a `$smarty.const.` prefix is stripped. - isResultSet reassignment ends at `;`, `,`, an unmatched closer, or `or`/`and`/`xor`; `?:`, `??`, `||`, `&&` keep the fetch inside the assignment. - ROOT_PATH guard is still required for includes that start with HTML (quick-fix declines without a file-leading open tag); stub detection ignores `;` inside quotes. -- `xor` in an early-exit condition is not a dominating isResultSet guard; language-constant cap is enforced before the 5001st name; scanner accepts `templates/…` and `blocks/…` manifest spellings and honours Cancel while walking template trees; the unregistered-template inspection applies the same manifest-spelling rule. A block template at `templates/blocks/foo.tpl` registered as `foo.tpl` (the XOOPS convention) is neither "unregistered" nor "missing"; `$modversion['blocks'][1]['template'] = '…'` assignment syntax is read as a registration alongside `'template' => '…'`; commented-out entries are ignored by the scanner too. A positive `isResultSet(...) xor …` guard is not a guard. The comment mask steps over quoted strings, so `//` or `#` inside a string (a URL in a description) no longer hides the rest of the line from the manifest, guard and template readers. A `module.json`-only module is not scanned for unregistered templates. The missing-registered-template inspection reads the manifest through the comment-only mask (it had masked the string literals it needed and never reported). `define()` must be a real call (`mydefine('_MI_…')` is not indexed). The register-template quick-fix ignores commented-out entries. A positive `isResultSet` guard does not cover a fetch inside a closure. The ROOT_PATH quick-fix is attached only where it can insert. An unreadable or oversized `xoops_version.php` yields one `SCAN_ERROR` instead of every template being reported unregistered. Commented-out `define()` lines are not indexed as language constants. +- `xor` in an early-exit condition is not a dominating isResultSet guard; language-constant cap is enforced before the 5001st name; scanner accepts `templates/…` and `blocks/…` manifest spellings and honours Cancel while walking template trees; the unregistered-template inspection applies the same manifest-spelling rule. A block template at `templates/blocks/foo.tpl` registered as `foo.tpl` (the XOOPS convention) is neither "unregistered" nor "missing"; `$modversion['blocks'][1]['template'] = '…'` assignment syntax is read as a registration alongside `'template' => '…'`; commented-out entries are ignored by the scanner too. A positive `isResultSet(...) xor …` guard is not a guard. The comment mask steps over quoted strings, so `//` or `#` inside a string (a URL in a description) no longer hides the rest of the line from the manifest, guard and template readers. A `module.json`-only module is not scanned for unregistered templates. The missing-registered-template inspection reads the manifest through the comment-only mask (it had masked the string literals it needed and never reported). `define()` must be a real call (`mydefine('_MI_…')` is not indexed). The register-template quick-fix ignores commented-out entries. A positive `isResultSet` guard does not cover a fetch inside a closure. The ROOT_PATH quick-fix is attached only where it can insert. An unreadable or oversized `xoops_version.php` yields one `SCAN_ERROR` instead of every template being reported unregistered. Commented-out `define()` lines are not indexed as language constants. Heredoc/nowdoc bodies are skipped by the comment-only mask, so `//` or `/*` inside them cannot hide later code. A VFS event on the `language` directory itself invalidates the constant cache. ## [1.0.0-alpha.3] — 1.0.0 Alpha 3 — 2026-09-02 diff --git a/src/main/java/org/xoops/support/completion/XoopsLanguageConstantParser.java b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantParser.java index ecd4231..2fbe328 100644 --- a/src/main/java/org/xoops/support/completion/XoopsLanguageConstantParser.java +++ b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantParser.java @@ -72,7 +72,7 @@ public static boolean isLanguagePath(@Nullable String path) { return false; } String p = path.replace('\\', '/').toLowerCase(Locale.ROOT); - return p.contains("/language/") + return (p.contains("/language/") || p.endsWith("/language")) && (p.endsWith(".php") || p.endsWith("/language") || !p.contains(".")); } diff --git a/src/main/java/org/xoops/support/inspections/PhpTextUtil.java b/src/main/java/org/xoops/support/inspections/PhpTextUtil.java index e84a016..6505691 100644 --- a/src/main/java/org/xoops/support/inspections/PhpTextUtil.java +++ b/src/main/java/org/xoops/support/inspections/PhpTextUtil.java @@ -83,17 +83,17 @@ static boolean looksLikeVendorOrCache(@NotNull PsiFile file) { int n = chars.length; while (i < n) { // Heredoc / nowdoc: << Date: Mon, 21 Sep 2026 10:05:14 -0400 Subject: [PATCH 13/20] docs: fold the review-round fixes into the 1.0.0-alpha.3 changelog entry This PR is the alpha.3 release, so the Unreleased bullets belong under [1.0.0-alpha.3] (dated 2026-09-21); Unreleased is empty again. What's New gets a one-line summary of the review hardening. --- CHANGELOG.md | 24 +++++++++++++----------- whats-new.html | 1 + 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 106c4e6..85e8f4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,19 @@ and this project uses [Semantic Versioning](https://semver.org/) with pre-releas ## Unreleased -### Fixed +_Nothing yet._ + +## [1.0.0-alpha.3] — 1.0.0 Alpha 3 — 2026-09-21 + +### Fixed (Goffy / wgSimpleAcc field report) + +- Inspections no longer report every finding twice (file-level `visitFile` now runs only on the view-provider's primary PSI file). +- `isResultSet` quick-fix inserts before the current PSI statement (Inspect Code batch apply no longer no-ops after the first click in a file). +- An early-exit `isResultSet` / `mysqli_result` guard covers later `fetch*` of the same variable, including `while (list(...) = $db->fetchRow($result))`. +- ROOT_PATH guard: `die` and `exit` after `namespace` / `use` are recognized; the quick-fix inserts *after* the namespace (never before — invalid PHP). +- ROOT_PATH guard is not required on 404/403 directory stubs, `admin/` CP scripts, `xoops_version.php`, or files whose first work is including `mainfile.php` / `header.php` / `admin_header.php`. + +### Fixed (release review, PR #4) - Replaced deprecated `StartupActivity` with `ProjectActivity`. - `FilenameIndex.getVirtualFilesByName` now passes `Project` (the two-arg overload is deprecated). @@ -21,16 +33,6 @@ and this project uses [Semantic Versioning](https://semver.org/) with pre-releas - ROOT_PATH guard is still required for includes that start with HTML (quick-fix declines without a file-leading open tag); stub detection ignores `;` inside quotes. - `xor` in an early-exit condition is not a dominating isResultSet guard; language-constant cap is enforced before the 5001st name; scanner accepts `templates/…` and `blocks/…` manifest spellings and honours Cancel while walking template trees; the unregistered-template inspection applies the same manifest-spelling rule. A block template at `templates/blocks/foo.tpl` registered as `foo.tpl` (the XOOPS convention) is neither "unregistered" nor "missing"; `$modversion['blocks'][1]['template'] = '…'` assignment syntax is read as a registration alongside `'template' => '…'`; commented-out entries are ignored by the scanner too. A positive `isResultSet(...) xor …` guard is not a guard. The comment mask steps over quoted strings, so `//` or `#` inside a string (a URL in a description) no longer hides the rest of the line from the manifest, guard and template readers. A `module.json`-only module is not scanned for unregistered templates. The missing-registered-template inspection reads the manifest through the comment-only mask (it had masked the string literals it needed and never reported). `define()` must be a real call (`mydefine('_MI_…')` is not indexed). The register-template quick-fix ignores commented-out entries. A positive `isResultSet` guard does not cover a fetch inside a closure. The ROOT_PATH quick-fix is attached only where it can insert. An unreadable or oversized `xoops_version.php` yields one `SCAN_ERROR` instead of every template being reported unregistered. Commented-out `define()` lines are not indexed as language constants. Heredoc/nowdoc bodies are skipped by the comment-only mask, so `//` or `/*` inside them cannot hide later code. A VFS event on the `language` directory itself invalidates the constant cache. -## [1.0.0-alpha.3] — 1.0.0 Alpha 3 — 2026-09-02 - -### Fixed (Goffy / wgSimpleAcc field report) - -- Inspections no longer report every finding twice (file-level `visitFile` now runs only on the view-provider's primary PSI file). -- `isResultSet` quick-fix inserts before the current PSI statement (Inspect Code batch apply no longer no-ops after the first click in a file). -- An early-exit `isResultSet` / `mysqli_result` guard covers later `fetch*` of the same variable, including `while (list(...) = $db->fetchRow($result))`. -- ROOT_PATH guard: `die` and `exit` after `namespace` / `use` are recognized; the quick-fix inserts *after* the namespace (never before — invalid PHP). -- ROOT_PATH guard is not required on 404/403 directory stubs, `admin/` CP scripts, `xoops_version.php`, or files whose first work is including `mainfile.php` / `header.php` / `admin_header.php`. - ### Added - Language-constant completion scans every `language/**/*.php` (not a five-name allowlist). diff --git a/whats-new.html b/whats-new.html index 634e5bb..e96e59c 100644 --- a/whats-new.html +++ b/whats-new.html @@ -7,6 +7,7 @@

    1.0.0 Alpha 3

  • ROOT_PATH guard: after namespace, accepts die, skips 404 stubs and admin/bootstrap entry points
  • Language constants from every language/**/*.php; Ctrl+B / Find Usages
  • Unregistered .tpl inspection; XOBJ_DTYPE_UNICODE_* rename quick-fix
  • +
  • Review hardening: Alpha 2 Core Version setting migrates; block templates in templates/blocks/, ['template'] = ... manifests and URLs in descriptions parse correctly; comment mask keeps strings and heredocs; commented-out define() is not indexed; no-op quick-fixes are not offered

1.0.0 Alpha 2

    From 34d27dddf16c7ee5b77b45017a64e11872904bba Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 21 Sep 2026 11:48:49 -0400 Subject: [PATCH 14/20] fix: guard insertion above unbraced control bodies, exact bootstrap names, define() in strings - InsertBeforeStatementQuickFix climbs out of an unbraced if / while / for / foreach body before inserting, so the early-exit guard lands before the control statement instead of becoming its body. - XoopsRootPathGuardPolicy bootstrap detection matches exact filenames with path-segment boundaries; custom-header.php and mainfile.php.bak no longer count as bootstrap includes. - XoopsLanguageConstantParser skips a define() that sits inside a string literal (checked against the fully masked source). - XoopsUnregisteredTemplateInspection accepts the bare name for a blocks/ relative path, same fallback as the scanner. - TUTORIAL: || and xor in a positive isResultSet condition are not a guard. --- CHANGELOG.md | 2 +- TUTORIAL.md | 2 +- .../XoopsLanguageConstantParser.java | 6 ++++++ .../InsertBeforeStatementQuickFix.java | 20 +++++++++++++++++++ .../support/inspections/PhpTextUtil.java | 2 +- .../inspections/XoopsRootPathGuardPolicy.java | 3 +-- .../XoopsUnregisteredTemplateInspection.java | 4 +++- .../XoopsLanguageConstantParserTest.java | 13 ++++++++++++ .../XoopsRootPathGuardPolicyTest.java | 18 +++++++++++++++++ 9 files changed, 64 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85e8f4d..780bb93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ _Nothing yet._ - Language-constant index keeps original spelling; resolution is exact-case (name recognition stays case-insensitive). Only a `$smarty.const.` prefix is stripped. - isResultSet reassignment ends at `;`, `,`, an unmatched closer, or `or`/`and`/`xor`; `?:`, `??`, `||`, `&&` keep the fetch inside the assignment. - ROOT_PATH guard is still required for includes that start with HTML (quick-fix declines without a file-leading open tag); stub detection ignores `;` inside quotes. -- `xor` in an early-exit condition is not a dominating isResultSet guard; language-constant cap is enforced before the 5001st name; scanner accepts `templates/…` and `blocks/…` manifest spellings and honours Cancel while walking template trees; the unregistered-template inspection applies the same manifest-spelling rule. A block template at `templates/blocks/foo.tpl` registered as `foo.tpl` (the XOOPS convention) is neither "unregistered" nor "missing"; `$modversion['blocks'][1]['template'] = '…'` assignment syntax is read as a registration alongside `'template' => '…'`; commented-out entries are ignored by the scanner too. A positive `isResultSet(...) xor …` guard is not a guard. The comment mask steps over quoted strings, so `//` or `#` inside a string (a URL in a description) no longer hides the rest of the line from the manifest, guard and template readers. A `module.json`-only module is not scanned for unregistered templates. The missing-registered-template inspection reads the manifest through the comment-only mask (it had masked the string literals it needed and never reported). `define()` must be a real call (`mydefine('_MI_…')` is not indexed). The register-template quick-fix ignores commented-out entries. A positive `isResultSet` guard does not cover a fetch inside a closure. The ROOT_PATH quick-fix is attached only where it can insert. An unreadable or oversized `xoops_version.php` yields one `SCAN_ERROR` instead of every template being reported unregistered. Commented-out `define()` lines are not indexed as language constants. Heredoc/nowdoc bodies are skipped by the comment-only mask, so `//` or `/*` inside them cannot hide later code. A VFS event on the `language` directory itself invalidates the constant cache. +- `xor` in an early-exit condition is not a dominating isResultSet guard; language-constant cap is enforced before the 5001st name; scanner accepts `templates/…` and `blocks/…` manifest spellings and honours Cancel while walking template trees; the unregistered-template inspection applies the same manifest-spelling rule. A block template at `templates/blocks/foo.tpl` registered as `foo.tpl` (the XOOPS convention) is neither "unregistered" nor "missing"; `$modversion['blocks'][1]['template'] = '…'` assignment syntax is read as a registration alongside `'template' => '…'`; commented-out entries are ignored by the scanner too. A positive `isResultSet(...) xor …` guard is not a guard. The comment mask steps over quoted strings, so `//` or `#` inside a string (a URL in a description) no longer hides the rest of the line from the manifest, guard and template readers. A `module.json`-only module is not scanned for unregistered templates. The missing-registered-template inspection reads the manifest through the comment-only mask (it had masked the string literals it needed and never reported). `define()` must be a real call (`mydefine('_MI_…')` is not indexed). The register-template quick-fix ignores commented-out entries. A positive `isResultSet` guard does not cover a fetch inside a closure. The ROOT_PATH quick-fix is attached only where it can insert. An unreadable or oversized `xoops_version.php` yields one `SCAN_ERROR` instead of every template being reported unregistered. Commented-out `define()` lines are not indexed as language constants. Heredoc/nowdoc bodies are skipped by the comment-only mask, so `//` or `/*` inside them cannot hide later code. A VFS event on the `language` directory itself invalidates the constant cache. A `define()` inside a string literal is not indexed. The isResultSet quick-fix inserts before the enclosing unbraced `if`/`while`/`for` rather than inside it. Bootstrap detection matches exact filenames (`custom-header.php`, `mainfile.php.bak` do not count). ### Added diff --git a/TUTORIAL.md b/TUTORIAL.md index 6f0401f..28aecb9 100644 --- a/TUTORIAL.md +++ b/TUTORIAL.md @@ -133,7 +133,7 @@ An early-exit guard covers later fetches of the same variable, including `while **What counts as guarded.** The analysis is textual, so it follows a few explicit rules rather than full control flow: - A negative check that throws, returns or exits (`if (!$db->isResultSet($result)) { return; }`) covers every later fetch of `$result` in the same block. It stops covering at a reassignment of `$result`, at a nested `function`, or when the enclosing `}` closes. -- A positive check (`if ($db->isResultSet($result)) { ... }`) covers fetches inside its body, brace-less body included. +- A positive check (`if ($db->isResultSet($result)) { ... }`) covers fetches inside its body, brace-less body included. `||` and `xor` in a positive condition disqualify it: `if ($db->isResultSet($result) || $fallback)` can enter the body with `$result === false`, so the fetch inside is still reported. `||` is fine only in the terminating negative form above. - `$result = $db->fetchArray($result)` inside a guarded region stays guarded: the fetch reads the old value. `$result = false or $db->fetchArray($result)` does not, because `or` binds looser than `=` and the assignment completes first. - `&&`, `and` and `xor` in the guard condition disqualify it. `if (!isResultSet($r) && $x) return;` can fall through while `$r` is still `false`. - Comments and strings are masked first, so a commented-out guard never counts. diff --git a/src/main/java/org/xoops/support/completion/XoopsLanguageConstantParser.java b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantParser.java index 2fbe328..257da64 100644 --- a/src/main/java/org/xoops/support/completion/XoopsLanguageConstantParser.java +++ b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantParser.java @@ -35,8 +35,14 @@ private XoopsLanguageConstantParser() { List out = new ArrayList<>(); // Comments masked, strings kept: a commented-out define() is not a definition, // and the mask preserves offsets so Ctrl+B still lands on the real name. + // The fully masked copy tells whether the define keyword itself sits in code: + // inside a string literal it is blanked there, so the occurrence is skipped. + String code = PhpTextUtil.maskCommentsAndStrings(phpSource); Matcher m = DEFINE.matcher(PhpTextUtil.maskCommentsOnly(phpSource)); while (m.find()) { + if (code.charAt(m.start()) == ' ') { + continue; + } out.add(new Occurrence(m.group(1), m.start(1))); } return out; diff --git a/src/main/java/org/xoops/support/inspections/InsertBeforeStatementQuickFix.java b/src/main/java/org/xoops/support/inspections/InsertBeforeStatementQuickFix.java index 0d3e23b..3636bce 100644 --- a/src/main/java/org/xoops/support/inspections/InsertBeforeStatementQuickFix.java +++ b/src/main/java/org/xoops/support/inspections/InsertBeforeStatementQuickFix.java @@ -7,6 +7,7 @@ import com.intellij.psi.PsiDocumentManager; import com.intellij.psi.PsiElement; import com.intellij.psi.util.PsiTreeUtil; +import com.jetbrains.php.lang.psi.elements.GroupStatement; import com.jetbrains.php.lang.psi.elements.Statement; import org.jetbrains.annotations.NotNull; @@ -42,6 +43,7 @@ public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descri if (stmt == null) { return; } + stmt = outermostUnbracedAncestor(stmt); Document document = DocumentEditHelper.documentOf(project, leaf); if (document == null) { return; @@ -64,6 +66,24 @@ public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descri PsiDocumentManager.getInstance(project).commitDocument(document); } + /** + * When the statement is the sole, unbraced body of an if / while / for / foreach, + * inserting before it would put the guard inside that control structure and push + * the fetch out of it. Climb to the control statement instead (an early-exit guard + * before the loop is equivalent) and stop at the first enclosing {@code { }} block. + */ + private static @NotNull Statement outermostUnbracedAncestor(@NotNull Statement stmt) { + Statement current = stmt; + while (true) { + Statement up = PsiTreeUtil.getParentOfType(current, Statement.class, true); + GroupStatement block = PsiTreeUtil.getParentOfType(current, GroupStatement.class, true); + if (up == null || (block != null && PsiTreeUtil.isAncestor(up, block, true))) { + return current; + } + current = up; + } + } + private static String guessIndent(@NotNull String text, int offset) { int start = text.lastIndexOf('\n', Math.max(0, offset - 1)); start = start < 0 ? 0 : start + 1; diff --git a/src/main/java/org/xoops/support/inspections/PhpTextUtil.java b/src/main/java/org/xoops/support/inspections/PhpTextUtil.java index 6505691..effc35c 100644 --- a/src/main/java/org/xoops/support/inspections/PhpTextUtil.java +++ b/src/main/java/org/xoops/support/inspections/PhpTextUtil.java @@ -73,7 +73,7 @@ static boolean looksLikeVendorOrCache(@NotNull PsiFile file) { * Mask comments, quoted strings, and heredoc/nowdoc bodies with spaces so * regex matches keep the same offsets but cannot hit non-code. */ - static @NotNull String maskCommentsAndStrings(@NotNull String text) { + public static @NotNull String maskCommentsAndStrings(@NotNull String text) { return maskInternal(text, true); } diff --git a/src/main/java/org/xoops/support/inspections/XoopsRootPathGuardPolicy.java b/src/main/java/org/xoops/support/inspections/XoopsRootPathGuardPolicy.java index 7e9af23..383a789 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsRootPathGuardPolicy.java +++ b/src/main/java/org/xoops/support/inspections/XoopsRootPathGuardPolicy.java @@ -49,8 +49,7 @@ public final class XoopsRootPathGuardPolicy { private static final Pattern BOOTSTRAP_INCLUDE = Pattern.compile( "(?i)^(?:include|include_once|require|require_once)\\b[^;]*" - + "(?:mainfile\\.php|admin_header\\.php|admin_footer\\.php|cp_header\\.php|common\\.php" - + "|(? registered = registeredTemplates(manifestPsi.getText()); - if (registered.contains(relative.toLowerCase(Locale.ROOT))) { + String key = relative.toLowerCase(Locale.ROOT); + if (registered.contains(key) + || (key.startsWith("blocks/") && registered.contains(key.substring("blocks/".length())))) { return; } PsiElement anchor = file.getFirstChild() != null ? file.getFirstChild() : file; diff --git a/src/test/java/org/xoops/support/completion/XoopsLanguageConstantParserTest.java b/src/test/java/org/xoops/support/completion/XoopsLanguageConstantParserTest.java index 2724f74..49e55ea 100644 --- a/src/test/java/org/xoops/support/completion/XoopsLanguageConstantParserTest.java +++ b/src/test/java/org/xoops/support/completion/XoopsLanguageConstantParserTest.java @@ -36,6 +36,19 @@ public void extractNameAcceptsQuotedAndBare() { assertNull(XoopsLanguageConstantParser.extractName("'_XX_FOO'")); } + @Test + public void parseIgnoresDefineInsideStringLiterals() { + String php = """ + occ = XoopsLanguageConstantParser.parse(php); + assertEquals(2, occ.size()); + assertEquals("_MI_HELP", occ.get(0).name()); + assertEquals("_MI_REAL", occ.get(1).name()); + } + @Test public void parseIgnoresCommentedOutDefines() { String php = """ diff --git a/src/test/java/org/xoops/support/inspections/XoopsRootPathGuardPolicyTest.java b/src/test/java/org/xoops/support/inspections/XoopsRootPathGuardPolicyTest.java index b78df1c..e5d944d 100644 --- a/src/test/java/org/xoops/support/inspections/XoopsRootPathGuardPolicyTest.java +++ b/src/test/java/org/xoops/support/inspections/XoopsRootPathGuardPolicyTest.java @@ -115,6 +115,24 @@ public void skipsBootstrapEntryPoint() { )); } + @Test + public void lookalikeBootstrapNamesStillRequireGuard() { + for (String inc : new String[] { + "include 'custom-header.php';", + "require 'mainfile.php.bak';", + "include_once __DIR__ . '/xoops_header.php';", + "require_once 'headerless.php';"}) { + assertTrue(inc, XoopsRootPathGuardPolicy.requiresGuard( + "C:/site/htdocs/modules/demo/include/common.php", + " Date: Mon, 21 Sep 2026 15:12:17 -0400 Subject: [PATCH 15/20] fix: read template registrations only from $modversion statements; elseif, version and resolve fixes - New XoopsManifestTemplates reader: 'file' / 'template' pairs and the ['file'] = / ['template'] = forms count only inside a statement that starts with $modversion['templates'] or $modversion['blocks']. Both template inspections and the scanner use it, so registration-like text in an unrelated string is ignored everywhere. - An elseif / else-if early exit is not a dominating isResultSet guard. - Core version detection requires a digit boundary (12.5 is not 2.5). - The isResultSet quick-fix declines when the selected statement assigns the result variable before the fetch. - Ctrl+B on a language constant prefers a define() in the same module (/modules//) before the global /english/ fallback. --- CHANGELOG.md | 2 +- .../XoopsLanguageConstantReference.java | 5 +- .../XoopsLanguageConstantsCache.java | 49 +++++++-- .../InsertBeforeStatementQuickFix.java | 12 ++ .../inspections/XoopsManifestTemplates.java | 104 ++++++++++++++++++ ...psMissingRegisteredTemplateInspection.java | 12 +- .../XoopsResultSetGuardInspection.java | 10 +- .../XoopsUnregisteredTemplateInspection.java | 24 +--- .../support/scanner/XoopsProjectScanner.java | 18 ++- .../XoopsLanguageConstantParserTest.java | 7 ++ .../XoopsManifestTemplatesTest.java | 56 ++++++++++ .../XoopsResultSetGuardInspectionTest.java | 15 +++ .../scanner/XoopsProjectScannerTest.java | 26 ++++- 13 files changed, 283 insertions(+), 57 deletions(-) create mode 100644 src/main/java/org/xoops/support/inspections/XoopsManifestTemplates.java create mode 100644 src/test/java/org/xoops/support/inspections/XoopsManifestTemplatesTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 780bb93..f5176da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ _Nothing yet._ - Language-constant index keeps original spelling; resolution is exact-case (name recognition stays case-insensitive). Only a `$smarty.const.` prefix is stripped. - isResultSet reassignment ends at `;`, `,`, an unmatched closer, or `or`/`and`/`xor`; `?:`, `??`, `||`, `&&` keep the fetch inside the assignment. - ROOT_PATH guard is still required for includes that start with HTML (quick-fix declines without a file-leading open tag); stub detection ignores `;` inside quotes. -- `xor` in an early-exit condition is not a dominating isResultSet guard; language-constant cap is enforced before the 5001st name; scanner accepts `templates/…` and `blocks/…` manifest spellings and honours Cancel while walking template trees; the unregistered-template inspection applies the same manifest-spelling rule. A block template at `templates/blocks/foo.tpl` registered as `foo.tpl` (the XOOPS convention) is neither "unregistered" nor "missing"; `$modversion['blocks'][1]['template'] = '…'` assignment syntax is read as a registration alongside `'template' => '…'`; commented-out entries are ignored by the scanner too. A positive `isResultSet(...) xor …` guard is not a guard. The comment mask steps over quoted strings, so `//` or `#` inside a string (a URL in a description) no longer hides the rest of the line from the manifest, guard and template readers. A `module.json`-only module is not scanned for unregistered templates. The missing-registered-template inspection reads the manifest through the comment-only mask (it had masked the string literals it needed and never reported). `define()` must be a real call (`mydefine('_MI_…')` is not indexed). The register-template quick-fix ignores commented-out entries. A positive `isResultSet` guard does not cover a fetch inside a closure. The ROOT_PATH quick-fix is attached only where it can insert. An unreadable or oversized `xoops_version.php` yields one `SCAN_ERROR` instead of every template being reported unregistered. Commented-out `define()` lines are not indexed as language constants. Heredoc/nowdoc bodies are skipped by the comment-only mask, so `//` or `/*` inside them cannot hide later code. A VFS event on the `language` directory itself invalidates the constant cache. A `define()` inside a string literal is not indexed. The isResultSet quick-fix inserts before the enclosing unbraced `if`/`while`/`for` rather than inside it. Bootstrap detection matches exact filenames (`custom-header.php`, `mainfile.php.bak` do not count). +- `xor` in an early-exit condition is not a dominating isResultSet guard; language-constant cap is enforced before the 5001st name; scanner accepts `templates/…` and `blocks/…` manifest spellings and honours Cancel while walking template trees; the unregistered-template inspection applies the same manifest-spelling rule. A block template at `templates/blocks/foo.tpl` registered as `foo.tpl` (the XOOPS convention) is neither "unregistered" nor "missing"; `$modversion['blocks'][1]['template'] = '…'` assignment syntax is read as a registration alongside `'template' => '…'`; commented-out entries are ignored by the scanner too. A positive `isResultSet(...) xor …` guard is not a guard. The comment mask steps over quoted strings, so `//` or `#` inside a string (a URL in a description) no longer hides the rest of the line from the manifest, guard and template readers. A `module.json`-only module is not scanned for unregistered templates. The missing-registered-template inspection reads the manifest through the comment-only mask (it had masked the string literals it needed and never reported). `define()` must be a real call (`mydefine('_MI_…')` is not indexed). The register-template quick-fix ignores commented-out entries. A positive `isResultSet` guard does not cover a fetch inside a closure. The ROOT_PATH quick-fix is attached only where it can insert. An unreadable or oversized `xoops_version.php` yields one `SCAN_ERROR` instead of every template being reported unregistered. Commented-out `define()` lines are not indexed as language constants. Heredoc/nowdoc bodies are skipped by the comment-only mask, so `//` or `/*` inside them cannot hide later code. A VFS event on the `language` directory itself invalidates the constant cache. A `define()` inside a string literal is not indexed. The isResultSet quick-fix inserts before the enclosing unbraced `if`/`while`/`for` rather than inside it. Bootstrap detection matches exact filenames (`custom-header.php`, `mainfile.php.bak` do not count). Template registrations are read only from `$modversion['templates']` / `$modversion['blocks']` statements (shared `XoopsManifestTemplates` reader). An `elseif` early exit is not a dominating guard. Core version detection ignores `12.5` / `12.7` / `14.0`. The isResultSet quick-fix declines when the statement assigns the result before fetching. Ctrl+B prefers a `define()` in the same module when several modules define the same name. ### Added diff --git a/src/main/java/org/xoops/support/completion/XoopsLanguageConstantReference.java b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantReference.java index f9b99cb..eba6abb 100644 --- a/src/main/java/org/xoops/support/completion/XoopsLanguageConstantReference.java +++ b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantReference.java @@ -2,6 +2,7 @@ import com.intellij.openapi.util.TextRange; import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiFile; import com.intellij.psi.PsiReferenceBase; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -30,7 +31,9 @@ public XoopsLanguageConstantReference( @Override public @Nullable PsiElement resolve() { - return XoopsLanguageConstantsCache.getInstance(getElement().getProject()).resolve(name); + PsiFile file = getElement().getContainingFile(); + return XoopsLanguageConstantsCache.getInstance(getElement().getProject()) + .resolve(name, file == null ? null : file.getVirtualFile()); } @Override diff --git a/src/main/java/org/xoops/support/completion/XoopsLanguageConstantsCache.java b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantsCache.java index 55f9ee8..151aff4 100644 --- a/src/main/java/org/xoops/support/completion/XoopsLanguageConstantsCache.java +++ b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantsCache.java @@ -105,6 +105,14 @@ public void invalidate() { * Exact spelling only: {@code _MI_FOO} does not resolve to {@code define('_mi_foo')}. */ public @Nullable PsiElement resolve(@NotNull String name) { + return resolve(name, null); + } + + /** + * Same as {@link #resolve(String)}, preferring a definition in the same module as + * {@code from} (the {@code /modules//} segment) when several modules define the name. + */ + public @Nullable PsiElement resolve(@NotNull String name, @Nullable VirtualFile from) { Index index = getIndex(); if (index == null) { return null; @@ -113,14 +121,7 @@ public void invalidate() { if (defs == null || defs.isEmpty()) { return null; } - Def chosen = defs.get(0); - for (Def d : defs) { - String path = d.file.getPath().replace('\\', '/').toLowerCase(Locale.ROOT); - if (path.contains("/english/")) { - chosen = d; - break; - } - } + Def chosen = pickDefinition(defs, from == null ? null : moduleSegment(from.getPath())); if (project.isDisposed()) { return null; } @@ -191,6 +192,38 @@ public void dispose() { return new Index(Collections.unmodifiableSet(names), Map.copyOf(defs)); } + /** Same module and /english/ first, then same module, then any /english/, then the first. */ + static @NotNull Def pickDefinition(@NotNull List defs, @Nullable String moduleSegment) { + Def sameModule = null; + Def english = null; + for (Def d : defs) { + String path = d.file.getPath().replace('\\', '/').toLowerCase(Locale.ROOT); + boolean inModule = moduleSegment != null && path.contains(moduleSegment); + boolean isEnglish = path.contains("/english/"); + if (inModule && isEnglish) { + return d; + } + if (inModule && sameModule == null) { + sameModule = d; + } + if (isEnglish && english == null) { + english = d; + } + } + return sameModule != null ? sameModule : (english != null ? english : defs.get(0)); + } + + /** {@code /modules//} of a path, lower-case, or null when not under modules/. */ + static @Nullable String moduleSegment(@NotNull String path) { + String p = path.replace('\\', '/').toLowerCase(Locale.ROOT); + int i = p.indexOf("/modules/"); + if (i < 0) { + return null; + } + int end = p.indexOf('/', i + "/modules/".length()); + return end < 0 ? null : p.substring(i, end + 1); + } + private record Def(@NotNull VirtualFile file, int offset) { } diff --git a/src/main/java/org/xoops/support/inspections/InsertBeforeStatementQuickFix.java b/src/main/java/org/xoops/support/inspections/InsertBeforeStatementQuickFix.java index 3636bce..ce7669e 100644 --- a/src/main/java/org/xoops/support/inspections/InsertBeforeStatementQuickFix.java +++ b/src/main/java/org/xoops/support/inspections/InsertBeforeStatementQuickFix.java @@ -9,6 +9,8 @@ import com.intellij.psi.util.PsiTreeUtil; import com.jetbrains.php.lang.psi.elements.GroupStatement; import com.jetbrains.php.lang.psi.elements.Statement; + +import java.util.regex.Pattern; import org.jetbrains.annotations.NotNull; /** @@ -57,6 +59,11 @@ public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descri if (XoopsResultSetGuardInspection.isFetchGuardedAt(text, fetchOffset, resultVar)) { return; } + if (assignsBeforeFetch(text.substring(insertAt, Math.max(insertAt, fetchOffset)))) { + // e.g. while (($result = $db->query($sql)) && $db->fetchRow($result)): + // a guard above the statement would test a stale value. Leave it to the user. + return; + } String indent = guessIndent(text, insertAt); String block = indent + "if (!" + dbExpr + "->isResultSet(" + resultVar + ") || !" + resultVar + " instanceof \\mysqli_result) {\n" @@ -84,6 +91,11 @@ public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descri } } + private boolean assignsBeforeFetch(@NotNull String statementPrefix) { + return Pattern.compile(Pattern.quote(resultVar) + "(?![\\w])\\s*=(?![=>])") + .matcher(statementPrefix).find(); + } + private static String guessIndent(@NotNull String text, int offset) { int start = text.lastIndexOf('\n', Math.max(0, offset - 1)); start = start < 0 ? 0 : start + 1; diff --git a/src/main/java/org/xoops/support/inspections/XoopsManifestTemplates.java b/src/main/java/org/xoops/support/inspections/XoopsManifestTemplates.java new file mode 100644 index 0000000..51f9e9b --- /dev/null +++ b/src/main/java/org/xoops/support/inspections/XoopsManifestTemplates.java @@ -0,0 +1,104 @@ +package org.xoops.support.inspections; + +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Template registrations in {@code xoops_version.php}, shared by the two template + * inspections and the project scanner so they agree on what counts as registered. + * + *

    A registration is a {@code 'file' => 'x.tpl'} / {@code 'template' => 'x.tpl'} pair, or the + * {@code ['file'] = 'x.tpl'} / {@code ['template'] = 'x.tpl'} assignment form, that sits inside a + * statement starting with {@code $modversion['templates']} or {@code $modversion['blocks']}. + * Registration-like text in an unrelated string ({@code $help = "'file' => 'x.tpl'";}) is ignored. + */ +public final class XoopsManifestTemplates { + + /** A registered template name and the offset of that name in the manifest text. */ + public record Registration(@NotNull String name, int nameOffset) { + } + + private static final Pattern MODVERSION_TEMPLATES = Pattern.compile( + "(?i)\\$modversion\\s*\\[\\s*['\"](?:templates|blocks)['\"]\\s*\\]" + ); + private static final Pattern FILE_OR_TEMPLATE = Pattern.compile( + "(?is)['\"](?:file|template)['\"]\\s*\\]?\\s*=>?\\s*['\"]([^'\"]+\\.tpl)['\"]" + ); + + private XoopsManifestTemplates() { + } + + /** + * @param commentMasked manifest source with comments masked and strings kept + * ({@link PhpTextUtil#maskCommentsOnly(String)}); offsets are preserved + */ + public static @NotNull List find(@NotNull String commentMasked) { + List out = new ArrayList<>(); + Set seen = new LinkedHashSet<>(); + Matcher stmt = MODVERSION_TEMPLATES.matcher(commentMasked); + int searchFrom = 0; + while (stmt.find(searchFrom)) { + int end = statementEnd(commentMasked, stmt.end()); + Matcher m = FILE_OR_TEMPLATE.matcher(commentMasked); + m.region(stmt.end(), end); + while (m.find()) { + if (seen.add(m.start(1))) { + out.add(new Registration(m.group(1).replace('\\', '/'), m.start(1))); + } + } + searchFrom = Math.max(end, stmt.end()); + if (searchFrom >= commentMasked.length()) { + break; + } + } + return out; + } + + /** + * Lower-case lookup keys for every registration in {@code manifestText} (raw source). + * Each name is added as spelled, and also relative to {@code templates/} and to + * {@code blocks/}, so the XOOPS bare-name convention and module-root spellings both match. + */ + public static @NotNull Set keys(@NotNull String manifestText) { + Set out = new LinkedHashSet<>(); + for (Registration r : find(PhpTextUtil.maskCommentsOnly(manifestText))) { + String key = r.name().toLowerCase(Locale.ROOT); + out.add(key); + if (key.startsWith("templates/")) { + key = key.substring("templates/".length()); + out.add(key); + } + if (key.startsWith("blocks/")) { + out.add(key.substring("blocks/".length())); + } + } + return out; + } + + /** Offset just past the {@code ;} that ends the statement, skipping {@code ;} inside quotes. */ + private static int statementEnd(@NotNull String text, int from) { + char quote = 0; + for (int i = from; i < text.length(); i++) { + char c = text.charAt(i); + if (quote != 0) { + if (c == '\\') { + i++; + } else if (c == quote) { + quote = 0; + } + } else if (c == '\'' || c == '"') { + quote = c; + } else if (c == ';') { + return i + 1; + } + } + return text.length(); + } +} diff --git a/src/main/java/org/xoops/support/inspections/XoopsMissingRegisteredTemplateInspection.java b/src/main/java/org/xoops/support/inspections/XoopsMissingRegisteredTemplateInspection.java index acdc81d..c44dc1b 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsMissingRegisteredTemplateInspection.java +++ b/src/main/java/org/xoops/support/inspections/XoopsMissingRegisteredTemplateInspection.java @@ -9,17 +9,12 @@ import org.jetbrains.annotations.NotNull; import org.xoops.support.XoopsSupportPlugin; -import java.util.regex.Matcher; -import java.util.regex.Pattern; /** * Flags templates listed in xoops_version.php that are missing on disk. */ public final class XoopsMissingRegisteredTemplateInspection extends LocalInspectionTool { - private static final Pattern REGISTERED_TEMPLATE = Pattern.compile( - "(?is)['\"](?:file|template)['\"]\\s*\\]?\\s*=>?\\s*['\"]([^'\"]+\\.tpl)['\"]" - ); @Override public @NotNull PsiElementVisitor buildVisitor(@NotNull ProblemsHolder holder, boolean isOnTheFly) { @@ -40,15 +35,14 @@ public void visitFile(@NotNull PsiFile file) { String text = file.getText(); // Comments only: the registration key and file name are string literals. String code = PhpTextUtil.maskCommentsOnly(text); - Matcher m = REGISTERED_TEMPLATE.matcher(code); - while (m.find()) { - String template = m.group(1).replace('\\', '/'); + for (XoopsManifestTemplates.Registration reg : XoopsManifestTemplates.find(code)) { + String template = reg.name(); boolean exists = childExists(moduleRoot, "templates/" + template) || childExists(moduleRoot, "templates/blocks/" + template) || childExists(moduleRoot, "blocks/" + template) || childExists(moduleRoot, template); if (!exists) { - PsiElement leaf = PhpTextUtil.leafAt(file, m.start(1)); + PsiElement leaf = PhpTextUtil.leafAt(file, reg.nameOffset()); if (leaf != null) { holder.registerProblem( leaf, diff --git a/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java b/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java index 0fdecca..e0cc125 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java +++ b/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java @@ -162,7 +162,9 @@ private static boolean hasDominatingEarlyExit( ) { for (int i = before.size() - 1; i >= 0; i--) { IfCond ic = before.get(i); - if (ic.ifEnd > fetchOffset) { + if (ic.ifEnd > fetchOffset || ic.chained) { + // An elseif / else-if branch is skipped whenever an earlier branch matched, + // so its early exit proves nothing about the fall-through path. continue; } if (isSafeEarlyExitCondition(ic.condition, resultVar) @@ -312,7 +314,8 @@ private static boolean hasFunctionKeyword(@NotNull String code, int from, int to int semi = text.indexOf(';', after); ifEnd = semi < 0 ? text.length() : semi + 1; } - out.add(new IfCond(m.start(), openParen, closeParen, openBrace, bodyStart, ifEnd, cond)); + boolean chained = m.group().regionMatches(true, 0, "else", 0, 4); + out.add(new IfCond(m.start(), openParen, closeParen, openBrace, bodyStart, ifEnd, cond, chained)); } return out; } @@ -539,7 +542,8 @@ private record IfCond( int openBrace, int bodyStart, int ifEnd, - @NotNull String condition + @NotNull String condition, + boolean chained ) { } } diff --git a/src/main/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspection.java b/src/main/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspection.java index 71f3dec..afdab01 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspection.java +++ b/src/main/java/org/xoops/support/inspections/XoopsUnregisteredTemplateInspection.java @@ -10,11 +10,8 @@ import org.jetbrains.annotations.Nullable; import org.xoops.support.XoopsSupportPlugin; -import java.util.LinkedHashSet; import java.util.Locale; import java.util.Set; -import java.util.regex.Matcher; -import java.util.regex.Pattern; /** * Flags a module {@code .tpl} that sits under {@code templates/} (or {@code blocks/}) @@ -23,9 +20,6 @@ */ public final class XoopsUnregisteredTemplateInspection extends LocalInspectionTool { - private static final Pattern REGISTERED_TEMPLATE = Pattern.compile( - "(?is)['\"](?:file|template)['\"]\\s*\\]?\\s*=>?\\s*['\"]([^'\"]+\\.tpl)['\"]" - ); @Override public @NotNull PsiElementVisitor buildVisitor(@NotNull ProblemsHolder holder, boolean isOnTheFly) { @@ -78,23 +72,9 @@ public void visitFile(@NotNull PsiFile file) { return vf; } + /** Registered names as lookup keys; see {@link XoopsManifestTemplates#keys(String)}. */ static @NotNull Set registeredTemplates(@NotNull String manifestText) { - Set out = new LinkedHashSet<>(); - Matcher m = REGISTERED_TEMPLATE.matcher(PhpTextUtil.maskCommentsOnly(manifestText)); - while (m.find()) { - String key = m.group(1).replace('\\', '/').toLowerCase(Locale.ROOT); - out.add(key); - // Accept module-root spellings too; relativeTemplateName() is relative - // to templates/ or blocks/ (same rule as XoopsProjectScanner). - if (key.startsWith("templates/")) { - key = key.substring("templates/".length()); - out.add(key); - } - if (key.startsWith("blocks/")) { - out.add(key.substring("blocks/".length())); - } - } - return out; + return XoopsManifestTemplates.keys(manifestText); } private static @Nullable String relativeTemplateName(@NotNull VirtualFile tpl) { diff --git a/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java b/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java index 85c997d..716ab75 100644 --- a/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java +++ b/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java @@ -3,6 +3,7 @@ import com.intellij.openapi.progress.ProgressManager; import org.jetbrains.annotations.NotNull; import org.xoops.support.inspections.PhpTextUtil; +import org.xoops.support.inspections.XoopsManifestTemplates; import java.io.IOException; import java.nio.file.Files; @@ -36,12 +37,9 @@ public final class XoopsProjectScanner { "smarty_compile", "templates_c", "uploads", "vendor", "xoops_data" ); - static final Pattern VERSION_25 = Pattern.compile("(?i)(?:XOOPS[ _-]?)?2\\.5(?:[^0-9]|$)"); - static final Pattern VERSION_27 = Pattern.compile("(?i)(?:XOOPS[ _-]?)?2\\.7(?:[^0-9]|$)"); - static final Pattern VERSION_40 = Pattern.compile("(?i)(?:XOOPS[ _-]?)?4\\.0(?:[^0-9]|$)"); - static final Pattern REGISTERED_TEMPLATE = Pattern.compile( - "(?is)['\"](?:file|template)['\"]\\s*\\]?\\s*=>?\\s*['\"]([^'\"]+\\.tpl)['\"]" - ); + static final Pattern VERSION_25 = Pattern.compile("(?i)(?:XOOPS[ _-]?)?(?)\\s*['\"]([a-z0-9_-]+)['\"]" ); @@ -331,9 +329,9 @@ static void checkRegisteredTemplates(Path moduleRoot, List finding Set registered = new LinkedHashSet<>(); if (!content.isEmpty()) { // Masked copy keeps offsets, so lineAt() on the original content stays right. - Matcher matcher = REGISTERED_TEMPLATE.matcher(PhpTextUtil.maskCommentsOnly(content)); - while (matcher.find()) { - String template = matcher.group(1).replace('\\', '/'); + for (XoopsManifestTemplates.Registration reg + : XoopsManifestTemplates.find(PhpTextUtil.maskCommentsOnly(content))) { + String template = reg.name(); String key = template.toLowerCase(Locale.ROOT); registered.add(key); // A manifest may spell the path from the module root; the walk keys are @@ -353,7 +351,7 @@ static void checkRegisteredTemplates(Path moduleRoot, List finding findings.add(new XoopsFinding( "MISSING_REGISTERED_TEMPLATE", manifest, - lineAt(content, matcher.start(1)), + lineAt(content, reg.nameOffset()), "Registered template is missing: " + template )); } diff --git a/src/test/java/org/xoops/support/completion/XoopsLanguageConstantParserTest.java b/src/test/java/org/xoops/support/completion/XoopsLanguageConstantParserTest.java index 49e55ea..a60ad62 100644 --- a/src/test/java/org/xoops/support/completion/XoopsLanguageConstantParserTest.java +++ b/src/test/java/org/xoops/support/completion/XoopsLanguageConstantParserTest.java @@ -99,6 +99,13 @@ public void extractNamePreservesSpellingAndSmartyConst() { assertNull(XoopsLanguageConstantParser.extractName("'customer.const._MI_FOO'")); } + @Test + public void moduleSegmentIsExtracted() { + assertEquals("/modules/news/", + org.xoops.support.completion.XoopsLanguageConstantsCache.moduleSegment("C:/site/htdocs/modules/News/language/english/main.php")); + assertNull(org.xoops.support.completion.XoopsLanguageConstantsCache.moduleSegment("C:/site/htdocs/class/Foo.php")); + } + @Test public void languagePathFilter() { assertTrue(XoopsLanguageConstantParser.isLanguagePath("C:/m/language/english/search.php")); diff --git a/src/test/java/org/xoops/support/inspections/XoopsManifestTemplatesTest.java b/src/test/java/org/xoops/support/inspections/XoopsManifestTemplatesTest.java new file mode 100644 index 0000000..3429803 --- /dev/null +++ b/src/test/java/org/xoops/support/inspections/XoopsManifestTemplatesTest.java @@ -0,0 +1,56 @@ +package org.xoops.support.inspections; + +import org.junit.Test; + +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public final class XoopsManifestTemplatesTest { + + @Test + public void registrationLikeTextOutsideModversionIsIgnored() { + String manifest = """ + 'ghost.tpl'"; + $other['templates'][] = ['file' => 'not_modversion.tpl']; + $modversion['templates'][] = ['file' => 'real.tpl', 'description' => 'x']; + """; + Set keys = XoopsManifestTemplates.keys(manifest); + assertTrue(keys.contains("real.tpl")); + assertFalse(keys.contains("ghost.tpl")); + assertFalse(keys.contains("not_modversion.tpl")); + } + + @Test + public void singleStatementArrayYieldsEveryFile() { + String manifest = """ + 'a.tpl', 'description' => 'see https://xoops.org; ok'], + ['file' => 'b.tpl'], + ]; + $modversion['blocks'][1]['template'] = 'c_block.tpl'; + // $modversion['templates'][] = ['file' => 'commented.tpl']; + """; + List regs = + XoopsManifestTemplates.find(PhpTextUtil.maskCommentsOnly(manifest)); + assertEquals(3, regs.size()); + assertEquals("a.tpl", regs.get(0).name()); + assertEquals(manifest.indexOf("a.tpl"), regs.get(0).nameOffset()); + assertEquals("b.tpl", regs.get(1).name()); + assertEquals("c_block.tpl", regs.get(2).name()); + } + + @Test + public void keysCoverBareAndPrefixedSpellings() { + Set keys = XoopsManifestTemplates.keys( + " 'templates/blocks/deep_block.tpl'];"); + assertTrue(keys.contains("templates/blocks/deep_block.tpl")); + assertTrue(keys.contains("blocks/deep_block.tpl")); + assertTrue(keys.contains("deep_block.tpl")); + } +} diff --git a/src/test/java/org/xoops/support/inspections/XoopsResultSetGuardInspectionTest.java b/src/test/java/org/xoops/support/inspections/XoopsResultSetGuardInspectionTest.java index c3c16ed..3a3c030 100644 --- a/src/test/java/org/xoops/support/inspections/XoopsResultSetGuardInspectionTest.java +++ b/src/test/java/org/xoops/support/inspections/XoopsResultSetGuardInspectionTest.java @@ -182,6 +182,21 @@ public void closureInsidePositiveGuardIsNotGuarded() { assertEquals(1, XoopsResultSetGuardInspection.unguardedFetchOffsets(php).size()); } + @Test + public void elseifEarlyExitDoesNotDominate() { + String php = """ + query($sql); + if ($skip) { + $log->info('skipped'); + } elseif (!$db->isResultSet($result)) { + return; + } + $row = $db->fetchArray($result); + """; + assertEquals(1, XoopsResultSetGuardInspection.unguardedFetchOffsets(php).size()); + } + @Test public void commentContainingIsResultSetDoesNotGuard() { String php = """ diff --git a/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java b/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java index 79a8c86..ac1bcc1 100644 --- a/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java +++ b/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java @@ -20,6 +20,9 @@ public void versionPatternsPinTheDocumentedCoreLines() { assertTrue(XoopsProjectScanner.VERSION_40.matcher("XOOPS 4.0.0").find()); assertFalse(XoopsProjectScanner.VERSION_25.matcher("XOOPS 2.7.3").find()); assertFalse(XoopsProjectScanner.VERSION_27.matcher("version 12.5-extra").find()); + assertFalse(XoopsProjectScanner.VERSION_25.matcher("version 12.5").find()); + assertFalse(XoopsProjectScanner.VERSION_27.matcher("php: 12.7").find()); + assertFalse(XoopsProjectScanner.VERSION_40.matcher("build 14.0").find()); } @Test @@ -28,9 +31,9 @@ public void manifestDirnameAndRegisteredTemplateRegexes() { $modversion['dirname'] = 'wgsimpleacc'; $modversion['templates'][] = ['file' => 'wgsimpleacc_index.tpl', 'description' => '']; """; - var m = XoopsProjectScanner.REGISTERED_TEMPLATE.matcher(manifest); - assertTrue(m.find()); - assertEquals("wgsimpleacc_index.tpl", m.group(1)); + var regs = org.xoops.support.inspections.XoopsManifestTemplates.find(manifest); + assertEquals(1, regs.size()); + assertEquals("wgsimpleacc_index.tpl", regs.get(0).name()); } @Test @@ -182,6 +185,23 @@ public void oversizedManifestReportsScanErrorNotUnregisteredTemplates() throws E } } + @Test + public void registrationLikeStringIsNotAMissingTemplate() throws Exception { + Path moduleRoot = Files.createTempDirectory("xoops-mod"); + try { + Files.writeString(moduleRoot.resolve("xoops_version.php"), """ + 'ghost.tpl'"; + """); + Files.createDirectories(moduleRoot.resolve("templates")); + List findings = new ArrayList<>(); + XoopsProjectScanner.checkRegisteredTemplates(moduleRoot, findings); + assertTrue(findings.toString(), findings.isEmpty()); + } finally { + deleteRecursively(moduleRoot); + } + } + @Test public void missingRegisteredTemplateScan() throws Exception { Path moduleRoot = Files.createTempDirectory("xoops-mod"); From 30dbd81cd85763886c1cb770e52e40fa9f156c8f Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 21 Sep 2026 18:25:49 -0400 Subject: [PATCH 16/20] fix: correct PHP masking, template paths, and scanner handling --- .../actions/ShowXoopsProjectInfoAction.java | 6 +- .../CreateMissingTemplateQuickFix.java | 24 ++-- .../InsertBeforeStatementQuickFix.java | 9 +- .../support/inspections/PhpTextUtil.java | 69 +++++++++- .../inspections/RegisterTemplateQuickFix.java | 44 ++++--- .../inspections/ReplacePsiTextQuickFix.java | 19 ++- ...XoopsDeprecatedUnicodeDtypeInspection.java | 2 +- .../inspections/XoopsManifestTemplates.java | 54 +++++--- ...psMissingRegisteredTemplateInspection.java | 8 +- .../inspections/XoopsRootPathGuardPolicy.java | 21 ++- .../XoopsUnregisteredTemplateInspection.java | 25 +--- .../support/scanner/XoopsProjectScanner.java | 123 +++++++++++------- .../support/ui/XoopsToolWindowPanel.java | 5 +- .../support/inspections/PhpTextUtilTest.java | 42 ++++++ .../XoopsManifestTemplatesTest.java | 30 ++++- .../XoopsResultSetGuardInspectionTest.java | 19 +++ .../XoopsRootPathGuardPolicyTest.java | 26 ++++ ...opsUnregisteredTemplateInspectionTest.java | 22 ++-- .../scanner/XoopsProjectScannerTest.java | 41 ++++++ 19 files changed, 444 insertions(+), 145 deletions(-) diff --git a/src/main/java/org/xoops/support/actions/ShowXoopsProjectInfoAction.java b/src/main/java/org/xoops/support/actions/ShowXoopsProjectInfoAction.java index ff359ee..a1a8837 100644 --- a/src/main/java/org/xoops/support/actions/ShowXoopsProjectInfoAction.java +++ b/src/main/java/org/xoops/support/actions/ShowXoopsProjectInfoAction.java @@ -16,6 +16,7 @@ import org.xoops.support.scanner.XoopsModuleInfo; import org.xoops.support.scanner.XoopsProjectReport; import org.xoops.support.scanner.XoopsProjectScanner; +import org.xoops.support.settings.XoopsSettingsState; import java.nio.file.Path; @@ -34,7 +35,10 @@ public void run(@NotNull ProgressIndicator indicator) { XoopsProjectReport report; try { indicator.setText("Scanning XOOPS modules (cancellable)…"); - report = new XoopsProjectScanner().scan(Path.of(basePath)); + report = new XoopsProjectScanner().scan( + Path.of(basePath), + XoopsSettingsState.getInstance(project).resolvedCoreVersion() + ); } catch (ProcessCanceledException pce) { // Let the progress framework treat this as a user cancel, not a failure dialog. throw pce; diff --git a/src/main/java/org/xoops/support/inspections/CreateMissingTemplateQuickFix.java b/src/main/java/org/xoops/support/inspections/CreateMissingTemplateQuickFix.java index 4a96454..7405471 100644 --- a/src/main/java/org/xoops/support/inspections/CreateMissingTemplateQuickFix.java +++ b/src/main/java/org/xoops/support/inspections/CreateMissingTemplateQuickFix.java @@ -12,14 +12,18 @@ import java.nio.charset.StandardCharsets; /** - * Creates templates/{name} under the module that owns xoops_version.php. + * Creates the on-disk template for a missing {@code xoops_version.php} registration. + * Block registrations land under {@code templates/blocks/} unless the name already + * includes a {@code templates/} or {@code blocks/} prefix. */ public final class CreateMissingTemplateQuickFix implements LocalQuickFix { private final String templateName; + private final boolean block; - public CreateMissingTemplateQuickFix(@NotNull String templateName) { + public CreateMissingTemplateQuickFix(@NotNull String templateName, boolean block) { this.templateName = templateName.replace('\\', '/'); + this.block = block; } @Override @@ -34,22 +38,24 @@ public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descri return; } VirtualFile moduleRoot = file.getVirtualFile().getParent(); + String relative = XoopsManifestTemplates.diskPath(templateName, block); try { WriteAction.runAndWait(() -> { - VirtualFile templates = moduleRoot.findChild("templates"); - if (templates == null) { - templates = moduleRoot.createChildDirectory(this, "templates"); - } - // Support nested names like admin/list.tpl - String[] parts = templateName.split("/"); - VirtualFile dir = templates; + String[] parts = relative.split("/"); + VirtualFile dir = moduleRoot; for (int i = 0; i < parts.length - 1; i++) { + if (dir == null) { + return; + } VirtualFile next = dir.findChild(parts[i]); if (next == null) { next = dir.createChildDirectory(this, parts[i]); } dir = next; } + if (dir == null) { + return; + } String leaf = parts[parts.length - 1]; if (dir.findChild(leaf) != null) { return; diff --git a/src/main/java/org/xoops/support/inspections/InsertBeforeStatementQuickFix.java b/src/main/java/org/xoops/support/inspections/InsertBeforeStatementQuickFix.java index ce7669e..0318d11 100644 --- a/src/main/java/org/xoops/support/inspections/InsertBeforeStatementQuickFix.java +++ b/src/main/java/org/xoops/support/inspections/InsertBeforeStatementQuickFix.java @@ -59,7 +59,7 @@ public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descri if (XoopsResultSetGuardInspection.isFetchGuardedAt(text, fetchOffset, resultVar)) { return; } - if (assignsBeforeFetch(text.substring(insertAt, Math.max(insertAt, fetchOffset)))) { + if (assignsResultBeforeFetch(text, insertAt, Math.max(insertAt, fetchOffset), resultVar)) { // e.g. while (($result = $db->query($sql)) && $db->fetchRow($result)): // a guard above the statement would test a stale value. Leave it to the user. return; @@ -91,9 +91,12 @@ public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descri } } - private boolean assignsBeforeFetch(@NotNull String statementPrefix) { + static boolean assignsResultBeforeFetch( + @NotNull String text, int from, int to, @NotNull String resultVar + ) { + String masked = PhpTextUtil.maskCommentsAndStrings(text).substring(from, to); return Pattern.compile(Pattern.quote(resultVar) + "(?![\\w])\\s*=(?![=>])") - .matcher(statementPrefix).find(); + .matcher(masked).find(); } private static String guessIndent(@NotNull String text, int offset) { diff --git a/src/main/java/org/xoops/support/inspections/PhpTextUtil.java b/src/main/java/org/xoops/support/inspections/PhpTextUtil.java index effc35c..cdcf8c0 100644 --- a/src/main/java/org/xoops/support/inspections/PhpTextUtil.java +++ b/src/main/java/org/xoops/support/inspections/PhpTextUtil.java @@ -81,7 +81,28 @@ static boolean looksLikeVendorOrCache(@NotNull PsiFile file) { char[] chars = text.toCharArray(); int i = 0; int n = chars.length; + // Snippets with no open tag (statement prefixes) are already PHP. + // Full files that contain a tag start outside PHP so HTML quotes cannot leak. + boolean inPhp = !containsPhpOpenTag(text); while (i < n) { + if (!inPhp) { + int tagLen = phpOpenTagLength(text, i); + if (tagLen > 0) { + inPhp = true; + i += tagLen; + continue; + } + if (chars[i] != '\n' && chars[i] != '\r') { + chars[i] = ' '; + } + i++; + continue; + } + if (i + 1 < n && chars[i] == '?' && chars[i + 1] == '>') { + inPhp = false; + i += 2; + continue; + } // Heredoc / nowdoc: <<')) { chars[i++] = ' '; } continue; } // # line comment if (chars[i] == '#') { - while (i < n && chars[i] != '\n') { + while (i < n && chars[i] != '\n' + && !(chars[i] == '?' && i + 1 < n && chars[i + 1] == '>')) { chars[i++] = ' '; } continue; @@ -237,6 +260,48 @@ static boolean looksLikeVendorOrCache(@NotNull PsiFile file) { return new String(chars); } + private static boolean containsPhpOpenTag(@NotNull String text) { + for (int i = 0; i < text.length(); i++) { + if (text.charAt(i) == '<' && phpOpenTagLength(text, i) > 0) { + return true; + } + } + return false; + } + + /** + * Length of a PHP open tag at {@code i}, or 0. Recognizes {@code = n || text.charAt(i) != '<' || text.charAt(i + 1) != '?') { + return 0; + } + if (i + 2 < n && text.charAt(i + 2) == '=') { + return 3; + } + if (startsIgnoreCase(text, i, " text.length()) { + return false; + } + return text.regionMatches(true, i, prefix, 0, n); + } + /** * First string-literal argument after {@code openParen} ('...' or "..."). * Returns content without quotes, or null if the first arg is not a string. diff --git a/src/main/java/org/xoops/support/inspections/RegisterTemplateQuickFix.java b/src/main/java/org/xoops/support/inspections/RegisterTemplateQuickFix.java index 9498bff..1607982 100644 --- a/src/main/java/org/xoops/support/inspections/RegisterTemplateQuickFix.java +++ b/src/main/java/org/xoops/support/inspections/RegisterTemplateQuickFix.java @@ -6,9 +6,11 @@ import com.intellij.openapi.project.Project; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.psi.PsiDocumentManager; +import com.intellij.psi.PsiElement; import com.intellij.psi.PsiFile; import com.intellij.psi.PsiManager; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.Locale; @@ -20,7 +22,7 @@ public final class RegisterTemplateQuickFix implements LocalQuickFix { private final String templateName; public RegisterTemplateQuickFix(@NotNull String templateName) { - this.templateName = templateName.replace('\\', '/'); + this.templateName = XoopsManifestTemplates.registrationName(templateName); } @Override @@ -28,27 +30,18 @@ public RegisterTemplateQuickFix(@NotNull String templateName) { return "Register template in xoops_version.php"; } + @Override + public @Nullable PsiElement getElementToMakeWritable(@NotNull PsiFile currentFile) { + PsiFile manifest = findManifestPsi(currentFile.getProject(), currentFile); + return manifest != null ? manifest : currentFile; + } + @Override public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descriptor) { PsiFile tplFile = descriptor.getPsiElement() == null ? null : descriptor.getPsiElement().getContainingFile(); - if (tplFile == null || tplFile.getVirtualFile() == null) { - return; - } - VirtualFile dir = tplFile.getVirtualFile().getParent(); - VirtualFile manifest = null; - while (dir != null) { - manifest = dir.findChild("xoops_version.php"); - if (manifest != null) { - break; - } - dir = dir.getParent(); - } - if (manifest == null) { - return; - } - PsiFile manifestPsi = PsiManager.getInstance(project).findFile(manifest); + PsiFile manifestPsi = findManifestPsi(project, tplFile); if (manifestPsi == null) { return; } @@ -57,7 +50,7 @@ public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descri return; } String text = document.getText(); - String lowerName = templateName.toLowerCase(Locale.ROOT); + String lowerName = XoopsManifestTemplates.diskPath(templateName, false).toLowerCase(Locale.ROOT); // Same parser as the inspection: a commented-out entry is not a registration. if (XoopsUnregisteredTemplateInspection.registeredTemplates(text).contains(lowerName)) { return; @@ -74,4 +67,19 @@ public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descri document.insertString(insertAt, entry); PsiDocumentManager.getInstance(project).commitDocument(document); } + + private static @Nullable PsiFile findManifestPsi(@NotNull Project project, @Nullable PsiFile fromFile) { + if (fromFile == null || fromFile.getVirtualFile() == null) { + return null; + } + VirtualFile dir = fromFile.getVirtualFile().getParent(); + while (dir != null) { + VirtualFile manifest = dir.findChild("xoops_version.php"); + if (manifest != null) { + return PsiManager.getInstance(project).findFile(manifest); + } + dir = dir.getParent(); + } + return null; + } } diff --git a/src/main/java/org/xoops/support/inspections/ReplacePsiTextQuickFix.java b/src/main/java/org/xoops/support/inspections/ReplacePsiTextQuickFix.java index 545f12f..65f2c64 100644 --- a/src/main/java/org/xoops/support/inspections/ReplacePsiTextQuickFix.java +++ b/src/main/java/org/xoops/support/inspections/ReplacePsiTextQuickFix.java @@ -5,7 +5,6 @@ import com.intellij.openapi.editor.Document; import com.intellij.openapi.project.Project; import com.intellij.openapi.util.TextRange; -import com.intellij.psi.PsiDocumentManager; import com.intellij.psi.PsiElement; import org.jetbrains.annotations.NotNull; @@ -17,10 +16,16 @@ public final class ReplacePsiTextQuickFix implements LocalQuickFix { private final String familyName; private final String replacement; + private final String expected; - public ReplacePsiTextQuickFix(@NotNull String familyName, @NotNull String replacement) { + public ReplacePsiTextQuickFix( + @NotNull String familyName, + @NotNull String replacement, + @NotNull String expected + ) { this.familyName = familyName; this.replacement = replacement; + this.expected = expected; } @Override @@ -42,7 +47,13 @@ public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descri if (range == null || range.getEndOffset() > document.getTextLength()) { return; } - document.replaceString(range.getStartOffset(), range.getEndOffset(), replacement); - PsiDocumentManager.getInstance(project).commitDocument(document); + DocumentEditHelper.replace( + project, + document, + range.getStartOffset(), + range.getEndOffset(), + replacement, + expected + ); } } diff --git a/src/main/java/org/xoops/support/inspections/XoopsDeprecatedUnicodeDtypeInspection.java b/src/main/java/org/xoops/support/inspections/XoopsDeprecatedUnicodeDtypeInspection.java index 32aeae9..116a2c1 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsDeprecatedUnicodeDtypeInspection.java +++ b/src/main/java/org/xoops/support/inspections/XoopsDeprecatedUnicodeDtypeInspection.java @@ -58,7 +58,7 @@ public void visitFile(@NotNull PsiFile file) { holder.registerProblem( leaf, "XOOPS: " + old + " is deprecated since 2.7.3; use " + next, - new ReplacePsiTextQuickFix("Replace with " + next, next) + new ReplacePsiTextQuickFix("Replace with " + next, next, old) ); } } diff --git a/src/main/java/org/xoops/support/inspections/XoopsManifestTemplates.java b/src/main/java/org/xoops/support/inspections/XoopsManifestTemplates.java index 51f9e9b..7f0ce8c 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsManifestTemplates.java +++ b/src/main/java/org/xoops/support/inspections/XoopsManifestTemplates.java @@ -21,12 +21,20 @@ */ public final class XoopsManifestTemplates { + public enum Section { + TEMPLATES, + BLOCKS + } + /** A registered template name and the offset of that name in the manifest text. */ - public record Registration(@NotNull String name, int nameOffset) { + public record Registration(@NotNull String name, int nameOffset, @NotNull Section section) { + public boolean block() { + return section == Section.BLOCKS; + } } private static final Pattern MODVERSION_TEMPLATES = Pattern.compile( - "(?i)\\$modversion\\s*\\[\\s*['\"](?:templates|blocks)['\"]\\s*\\]" + "(?i)\\$modversion\\s*\\[\\s*['\"](templates|blocks)['\"]\\s*\\]" ); private static final Pattern FILE_OR_TEMPLATE = Pattern.compile( "(?is)['\"](?:file|template)['\"]\\s*\\]?\\s*=>?\\s*['\"]([^'\"]+\\.tpl)['\"]" @@ -46,11 +54,12 @@ private XoopsManifestTemplates() { int searchFrom = 0; while (stmt.find(searchFrom)) { int end = statementEnd(commentMasked, stmt.end()); + Section section = "blocks".equalsIgnoreCase(stmt.group(1)) ? Section.BLOCKS : Section.TEMPLATES; Matcher m = FILE_OR_TEMPLATE.matcher(commentMasked); m.region(stmt.end(), end); while (m.find()) { if (seen.add(m.start(1))) { - out.add(new Registration(m.group(1).replace('\\', '/'), m.start(1))); + out.add(new Registration(m.group(1).replace('\\', '/'), m.start(1), section)); } } searchFrom = Math.max(end, stmt.end()); @@ -61,27 +70,38 @@ private XoopsManifestTemplates() { return out; } - /** - * Lower-case lookup keys for every registration in {@code manifestText} (raw source). - * Each name is added as spelled, and also relative to {@code templates/} and to - * {@code blocks/}, so the XOOPS bare-name convention and module-root spellings both match. - */ + /** Module-root-relative, lower-case disk paths, preserving block/page identity. */ public static @NotNull Set keys(@NotNull String manifestText) { Set out = new LinkedHashSet<>(); for (Registration r : find(PhpTextUtil.maskCommentsOnly(manifestText))) { - String key = r.name().toLowerCase(Locale.ROOT); - out.add(key); - if (key.startsWith("templates/")) { - key = key.substring("templates/".length()); - out.add(key); - } - if (key.startsWith("blocks/")) { - out.add(key.substring("blocks/".length())); - } + out.add(diskPath(r.name(), r.block()).toLowerCase(Locale.ROOT)); } return out; } + /** + * Module-root-relative path where a missing registered file should be created. + * Bare block names go under {@code templates/blocks/}; already-prefixed names stay as spelled. + */ + public static @NotNull String diskPath(@NotNull String name, boolean block) { + String n = name.replace('\\', '/'); + while (n.startsWith("/")) { + n = n.substring(1); + } + if (n.startsWith("templates/") || n.startsWith("blocks/")) { + return n; + } + return block ? "templates/blocks/" + n : "templates/" + n; + } + + /** Shortest registration spelling that still resolves to the same module-relative path. */ + public static @NotNull String registrationName(@NotNull String relativePath) { + String normalized = relativePath.replace('\\', '/'); + String candidate = normalized.startsWith("templates/") + ? normalized.substring("templates/".length()) : normalized; + return diskPath(candidate, false).equals(normalized) ? candidate : normalized; + } + /** Offset just past the {@code ;} that ends the statement, skipping {@code ;} inside quotes. */ private static int statementEnd(@NotNull String text, int from) { char quote = 0; diff --git a/src/main/java/org/xoops/support/inspections/XoopsMissingRegisteredTemplateInspection.java b/src/main/java/org/xoops/support/inspections/XoopsMissingRegisteredTemplateInspection.java index c44dc1b..4b35852 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsMissingRegisteredTemplateInspection.java +++ b/src/main/java/org/xoops/support/inspections/XoopsMissingRegisteredTemplateInspection.java @@ -37,17 +37,15 @@ public void visitFile(@NotNull PsiFile file) { String code = PhpTextUtil.maskCommentsOnly(text); for (XoopsManifestTemplates.Registration reg : XoopsManifestTemplates.find(code)) { String template = reg.name(); - boolean exists = childExists(moduleRoot, "templates/" + template) - || childExists(moduleRoot, "templates/blocks/" + template) - || childExists(moduleRoot, "blocks/" + template) - || childExists(moduleRoot, template); + boolean exists = childExists(moduleRoot, + XoopsManifestTemplates.diskPath(template, reg.block())); if (!exists) { PsiElement leaf = PhpTextUtil.leafAt(file, reg.nameOffset()); if (leaf != null) { holder.registerProblem( leaf, "XOOPS: registered template missing on disk: " + template, - new CreateMissingTemplateQuickFix(template) + new CreateMissingTemplateQuickFix(template, reg.block()) ); } } diff --git a/src/main/java/org/xoops/support/inspections/XoopsRootPathGuardPolicy.java b/src/main/java/org/xoops/support/inspections/XoopsRootPathGuardPolicy.java index 383a789..1a58803 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsRootPathGuardPolicy.java +++ b/src/main/java/org/xoops/support/inspections/XoopsRootPathGuardPolicy.java @@ -218,16 +218,25 @@ static boolean containsOpenTag(@NotNull String text) { return firstOpenTagEnd(text) >= 0; } - /** End offset of the first {@code registered = registeredTemplates(manifestPsi.getText()); String key = relative.toLowerCase(Locale.ROOT); - if (registered.contains(key) - || (key.startsWith("blocks/") && registered.contains(key.substring("blocks/".length())))) { + if (registered.contains(key)) { return; } PsiElement anchor = file.getFirstChild() != null ? file.getFirstChild() : file; @@ -77,20 +80,6 @@ public void visitFile(@NotNull PsiFile file) { return XoopsManifestTemplates.keys(manifestText); } - private static @Nullable String relativeTemplateName(@NotNull VirtualFile tpl) { - VirtualFile dir = tpl.getParent(); - StringBuilder tail = new StringBuilder(tpl.getName()); - while (dir != null) { - String dirName = dir.getName().toLowerCase(Locale.ROOT); - if ("templates".equals(dirName) || "blocks".equals(dirName)) { - return tail.toString().replace('\\', '/'); - } - tail.insert(0, dir.getName() + "/"); - dir = dir.getParent(); - } - return null; - } - private static @Nullable VirtualFile moduleRootOf(@NotNull VirtualFile tpl) { VirtualFile dir = tpl.getParent(); while (dir != null) { diff --git a/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java b/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java index 716ab75..5995d01 100644 --- a/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java +++ b/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java @@ -2,10 +2,12 @@ import com.intellij.openapi.progress.ProgressManager; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.xoops.support.inspections.PhpTextUtil; import org.xoops.support.inspections.XoopsManifestTemplates; import java.io.IOException; +import java.io.UncheckedIOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; @@ -55,6 +57,10 @@ public final class XoopsProjectScanner { ); public XoopsProjectReport scan(Path requestedRoot) { + return scan(requestedRoot, "Auto"); + } + + public XoopsProjectReport scan(Path requestedRoot, @NotNull String coreVersionSetting) { Path projectRoot = requestedRoot.toAbsolutePath().normalize(); Path webRoot = detectWebRoot(projectRoot); boolean standaloneModule = Files.isRegularFile(projectRoot.resolve("xoops_version.php")) @@ -67,16 +73,16 @@ public XoopsProjectReport scan(Path requestedRoot) { ); } - List moduleRoots = findModuleRoots(projectRoot, webRoot, standaloneModule); + List findings = new ArrayList<>(); + List moduleRoots = findModuleRoots(projectRoot, webRoot, standaloneModule, findings); // Inspect + scan each module in one cancel-aware loop so Cancel is observed // during metadata walks (inspectModule / countFiles), not only during source scan. // One check per module (not per file) at this level; file walks throttle below. List modules = new ArrayList<>(); - List findings = new ArrayList<>(); for (Path moduleRoot : moduleRoots) { ProgressManager.checkCanceled(); - modules.add(inspectModule(moduleRoot)); + modules.add(inspectModule(moduleRoot, findings)); scanModule(moduleRoot, findings); } modules.sort(Comparator.comparing(XoopsModuleInfo::dirname, String.CASE_INSENSITIVE_ORDER)); @@ -84,13 +90,42 @@ public XoopsProjectReport scan(Path requestedRoot) { .comparing((XoopsFinding f) -> f.path().toString(), String.CASE_INSENSITIVE_ORDER) .thenComparingInt(XoopsFinding::line)); - CoreVersion coreVersion = standaloneModule && !isCoreRoot(webRoot) - ? CoreVersion.MODULE_ONLY - : detectCoreVersion(projectRoot, webRoot); + CoreVersion coreVersion = resolveScanCoreVersion( + coreVersionSetting, standaloneModule && !isCoreRoot(webRoot), projectRoot, webRoot + ); return new XoopsProjectReport(true, projectRoot, webRoot, coreVersion, modules, findings); } + /** + * Explicit Settings → Core Version wins. {@code Auto} (and unknown values) fall back + * to layout detection ({@code MODULE_ONLY}) or {@link #detectCoreVersion}. + */ + static @NotNull CoreVersion resolveScanCoreVersion( + @NotNull String setting, + boolean standaloneModule, + @NotNull Path projectRoot, + @NotNull Path webRoot + ) { + CoreVersion fromSetting = coreVersionFromSetting(setting); + if (fromSetting != null) { + return fromSetting; + } + if (standaloneModule) { + return CoreVersion.MODULE_ONLY; + } + return detectCoreVersion(projectRoot, webRoot); + } + + static @Nullable CoreVersion coreVersionFromSetting(@NotNull String setting) { + return switch (setting.trim()) { + case "2.5" -> CoreVersion.XOOPS_25; + case "2.7" -> CoreVersion.XOOPS_27; + case "4.0" -> CoreVersion.XOOPS_40; + default -> null; + }; + } + private static Path detectWebRoot(Path projectRoot) { if (isCoreRoot(projectRoot)) { return projectRoot; @@ -106,7 +141,7 @@ private static boolean isCoreRoot(Path candidate) { return Files.isRegularFile(candidate.resolve("mainfile.php")); } - private static List findModuleRoots(Path projectRoot, Path webRoot, boolean standaloneModule) { + private static List findModuleRoots(Path projectRoot, Path webRoot, boolean standaloneModule, List findings) { if (standaloneModule && !isCoreRoot(webRoot)) { return List.of(projectRoot); } @@ -120,21 +155,22 @@ private static List findModuleRoots(Path projectRoot, Path webRoot, boolea .filter(path -> Files.isRegularFile(path.resolve("xoops_version.php")) || Files.isRegularFile(path.resolve("module.json"))) .toList(); - } catch (IOException ignored) { + } catch (IOException | UncheckedIOException exception) { + findings.add(scanError(modulesDirectory, "Could not list modules: " + exception.getMessage())); return List.of(); } } - private XoopsModuleInfo inspectModule(Path moduleRoot) { + private XoopsModuleInfo inspectModule(Path moduleRoot, List findings) { return new XoopsModuleInfo( readModuleDirname(moduleRoot), moduleRoot, Files.isRegularFile(moduleRoot.resolve("xoops_version.php")), Files.isRegularFile(moduleRoot.resolve("module.json")), - countFiles(moduleRoot.resolve("templates"), ".tpl"), - countFiles(moduleRoot.resolve("language"), ".php"), - countFiles(moduleRoot.resolve("preloads"), ".php"), - countFiles(moduleRoot.resolve("class"), ".php") + countFiles(moduleRoot.resolve("src"), ".php") + countFiles(moduleRoot.resolve("templates"), ".tpl", findings), + countFiles(moduleRoot.resolve("language"), ".php", findings), + countFiles(moduleRoot.resolve("preloads"), ".php", findings), + countFiles(moduleRoot.resolve("class"), ".php", findings) + countFiles(moduleRoot.resolve("src"), ".php", findings) ); } @@ -144,7 +180,7 @@ private static String readModuleDirname(Path moduleRoot) { return matcher.find() ? matcher.group(1) : moduleRoot.getFileName().toString(); } - private static long countFiles(Path directory, String suffix) { + private static long countFiles(Path directory, String suffix, List findings) { if (!Files.isDirectory(directory)) { return 0; } @@ -158,7 +194,8 @@ private static long countFiles(Path directory, String suffix) { }) .filter(path -> path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(suffix)) .count(); - } catch (IOException ignored) { + } catch (IOException | UncheckedIOException exception) { + findings.add(scanError(directory, "Could not count files: " + exception.getMessage())); return 0; } } @@ -252,13 +289,10 @@ private void scanModule(Path moduleRoot, List findings) { checkCanceledEvery(seen); scanSourceFile(path, findings); }); + } catch (UncheckedIOException exception) { + findings.add(scanError(moduleRoot, "Could not scan module: " + walkMessage(exception))); } catch (IOException exception) { - findings.add(new XoopsFinding( - "SCAN_ERROR", - moduleRoot, - 1, - "Could not scan module: " + exception.getMessage() - )); + findings.add(scanError(moduleRoot, "Could not scan module: " + exception.getMessage())); } } @@ -332,21 +366,9 @@ static void checkRegisteredTemplates(Path moduleRoot, List finding for (XoopsManifestTemplates.Registration reg : XoopsManifestTemplates.find(PhpTextUtil.maskCommentsOnly(content))) { String template = reg.name(); - String key = template.toLowerCase(Locale.ROOT); - registered.add(key); - // A manifest may spell the path from the module root; the walk keys are - // relative to templates/ or blocks/, so accept both spellings. - if (key.startsWith("templates/")) { - key = key.substring("templates/".length()); - registered.add(key); - } - if (key.startsWith("blocks/")) { - registered.add(key.substring("blocks/".length())); - } - boolean exists = Files.isRegularFile(moduleRoot.resolve("templates").resolve(template)) - || Files.isRegularFile(moduleRoot.resolve("templates/blocks").resolve(template)) - || Files.isRegularFile(moduleRoot.resolve("blocks").resolve(template)) - || Files.isRegularFile(moduleRoot.resolve(template)); + String relative = XoopsManifestTemplates.diskPath(template, reg.block()); + registered.add(relative.toLowerCase(Locale.ROOT)); + boolean exists = Files.isRegularFile(moduleRoot.resolve(relative)); if (!exists) { findings.add(new XoopsFinding( "MISSING_REGISTERED_TEMPLATE", @@ -357,11 +379,12 @@ static void checkRegisteredTemplates(Path moduleRoot, List finding } } } - addUnregisteredTemplates(moduleRoot.resolve("templates"), registered, findings); - addUnregisteredTemplates(moduleRoot.resolve("blocks"), registered, findings); + addUnregisteredTemplates(moduleRoot, moduleRoot.resolve("templates"), registered, findings); + addUnregisteredTemplates(moduleRoot, moduleRoot.resolve("blocks"), registered, findings); } private static void addUnregisteredTemplates( + Path moduleRoot, Path directory, Set registered, List findings @@ -375,11 +398,9 @@ private static void addUnregisteredTemplates( .filter(Files::isRegularFile) .filter(p -> p.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".tpl")) .forEach(path -> { - String relative = directory.relativize(path).toString().replace('\\', '/'); + String relative = moduleRoot.relativize(path).toString().replace('\\', '/'); String key = relative.toLowerCase(Locale.ROOT); - // templates/blocks/foo.tpl is registered as 'foo.tpl' by convention. - boolean listed = registered.contains(key) - || (key.startsWith("blocks/") && registered.contains(key.substring("blocks/".length()))); + boolean listed = registered.contains(key); if (!listed) { findings.add(new XoopsFinding( "UNREGISTERED_TEMPLATE", @@ -389,11 +410,25 @@ private static void addUnregisteredTemplates( )); } }); - } catch (IOException ignored) { - // skip unreadable template trees + } catch (UncheckedIOException exception) { + findings.add(scanError(directory, "Could not scan templates: " + walkMessage(exception))); + } catch (IOException exception) { + findings.add(scanError(directory, "Could not scan templates: " + exception.getMessage())); } } + private static @NotNull XoopsFinding scanError(@NotNull Path path, @NotNull String message) { + return new XoopsFinding("SCAN_ERROR", path, 1, message); + } + + private static @NotNull String walkMessage(@NotNull UncheckedIOException exception) { + Throwable cause = exception.getCause(); + String detail = cause != null && cause.getMessage() != null + ? cause.getMessage() + : exception.getMessage(); + return detail == null ? exception.getClass().getSimpleName() : detail; + } + private static int lineAt(String content, int offset) { int line = 1; int end = Math.min(offset, content.length()); diff --git a/src/main/java/org/xoops/support/ui/XoopsToolWindowPanel.java b/src/main/java/org/xoops/support/ui/XoopsToolWindowPanel.java index 9ab9df9..120e31a 100644 --- a/src/main/java/org/xoops/support/ui/XoopsToolWindowPanel.java +++ b/src/main/java/org/xoops/support/ui/XoopsToolWindowPanel.java @@ -119,7 +119,10 @@ public void run(@NotNull ProgressIndicator indicator) { try { indicator.setText("Scanning XOOPS modules (cancellable)…"); indicator.checkCanceled(); - XoopsProjectReport report = new XoopsProjectScanner().scan(Path.of(basePath)); + XoopsProjectReport report = new XoopsProjectScanner().scan( + Path.of(basePath), + XoopsSettingsState.getInstance(project).resolvedCoreVersion() + ); indicator.checkCanceled(); String html = new XoopsReportHtmlRenderer().render(report); ApplicationManager.getApplication().invokeLater( diff --git a/src/test/java/org/xoops/support/inspections/PhpTextUtilTest.java b/src/test/java/org/xoops/support/inspections/PhpTextUtilTest.java index 77df92a..b92fe50 100644 --- a/src/test/java/org/xoops/support/inspections/PhpTextUtilTest.java +++ b/src/test/java/org/xoops/support/inspections/PhpTextUtilTest.java @@ -8,6 +8,31 @@ public final class PhpTextUtilTest { + + @Test + public void htmlIsOpaqueToBothInspectionMasks() { + String source = "

    Don't use XOBJ_DTYPE_UNICODE_TXTBOX

    " + + "$modversion['templates'][] = ['file' => 'html.tpl'];"; + for (String masked : new String[]{PhpTextUtil.maskCommentsOnly(source), + PhpTextUtil.maskCommentsAndStrings(source)}) { + assertEquals(source.length(), masked.length()); + assertFalse(masked.contains("XOBJ_DTYPE_UNICODE_TXTBOX")); + assertFalse(masked.contains("html.tpl")); + assertTrue(masked.contains("$live = 1;")); + } + } + + @Test + public void closingTagEndsLineCommentsBeforeNextPhpRegion() { + for (String comment : new String[]{"//", "#"}) { + String source = "Don't + 'real.tpl']; + """; + String commentsOnly = PhpTextUtil.maskCommentsOnly(php); + assertEquals(php.length(), commentsOnly.length()); + assertFalse(commentsOnly.contains("defined('XOOPS_ROOT_PATH')")); + assertTrue(commentsOnly.contains("$modversion['templates'][] = ['file' => 'real.tpl']")); + String full = PhpTextUtil.maskCommentsAndStrings(php); + assertFalse(full.contains("real.tpl")); + assertTrue(full.contains("$modversion")); + } + @Test public void fullMaskWipesHeredocButKeepsOffsets() { String php = " 'real.tpl', 'description' => 'x']; """; Set keys = XoopsManifestTemplates.keys(manifest); - assertTrue(keys.contains("real.tpl")); - assertFalse(keys.contains("ghost.tpl")); - assertFalse(keys.contains("not_modversion.tpl")); + assertTrue(keys.contains("templates/real.tpl")); + assertFalse(keys.contains("templates/ghost.tpl")); + assertFalse(keys.contains("templates/not_modversion.tpl")); } @Test @@ -40,9 +40,29 @@ public void singleStatementArrayYieldsEveryFile() { XoopsManifestTemplates.find(PhpTextUtil.maskCommentsOnly(manifest)); assertEquals(3, regs.size()); assertEquals("a.tpl", regs.get(0).name()); + assertEquals(XoopsManifestTemplates.Section.TEMPLATES, regs.get(0).section()); assertEquals(manifest.indexOf("a.tpl"), regs.get(0).nameOffset()); assertEquals("b.tpl", regs.get(1).name()); assertEquals("c_block.tpl", regs.get(2).name()); + assertEquals(XoopsManifestTemplates.Section.BLOCKS, regs.get(2).section()); + assertTrue(regs.get(2).block()); + } + + @Test + public void diskPathPutsBareBlockNamesUnderTemplatesBlocks() { + assertEquals("templates/demo.tpl", XoopsManifestTemplates.diskPath("demo.tpl", false)); + assertEquals("templates/blocks/demo_block.tpl", XoopsManifestTemplates.diskPath("demo_block.tpl", true)); + assertEquals("templates/rooted.tpl", XoopsManifestTemplates.diskPath("templates/rooted.tpl", false)); + assertEquals("blocks/legacy.tpl", XoopsManifestTemplates.diskPath("blocks/legacy.tpl", true)); + } + + @Test + public void registrationSpellingPreservesNestedDirectories() { + assertEquals("foo.tpl", XoopsManifestTemplates.registrationName("templates/foo.tpl")); + for (String path : new String[]{"templates/templates/foo.tpl", "templates/blocks/foo.tpl", "blocks/foo.tpl"}) { + assertEquals(path, XoopsManifestTemplates.registrationName(path)); + assertEquals(path, XoopsManifestTemplates.diskPath(XoopsManifestTemplates.registrationName(path), false)); + } } @Test @@ -50,7 +70,7 @@ public void keysCoverBareAndPrefixedSpellings() { Set keys = XoopsManifestTemplates.keys( " 'templates/blocks/deep_block.tpl'];"); assertTrue(keys.contains("templates/blocks/deep_block.tpl")); - assertTrue(keys.contains("blocks/deep_block.tpl")); - assertTrue(keys.contains("deep_block.tpl")); + assertFalse(keys.contains("blocks/deep_block.tpl")); + assertFalse(keys.contains("deep_block.tpl")); } } diff --git a/src/test/java/org/xoops/support/inspections/XoopsResultSetGuardInspectionTest.java b/src/test/java/org/xoops/support/inspections/XoopsResultSetGuardInspectionTest.java index 3a3c030..c67f577 100644 --- a/src/test/java/org/xoops/support/inspections/XoopsResultSetGuardInspectionTest.java +++ b/src/test/java/org/xoops/support/inspections/XoopsResultSetGuardInspectionTest.java @@ -223,4 +223,23 @@ public void earlyExitStillGuardsLaterFetch() { int fetchAt = php.indexOf("fetchArray"); assertTrue(XoopsResultSetGuardInspection.isFetchGuardedAt(php, fetchAt, "$result")); } + + @Test + public void commentAssignmentDoesNotCountAsAssignBeforeFetch() { + for (String prefix : new String[]{"// $result = ignored\n$row = ", + "$log = '$result = ignored';\n$row = "}) { + assertFalse(InsertBeforeStatementQuickFix.assignsResultBeforeFetch( + prefix, 0, prefix.length(), "$result")); + } + String prefix = "($result = $db->query($sql)) && "; + assertTrue(InsertBeforeStatementQuickFix.assignsResultBeforeFetch( + prefix, 0, prefix.length(), "$result")); + } + + @Test + public void assignmentCheckMasksWholePhpDocumentBeforeSlicing() { + String source = "fetchArray($result));"; + assertFalse(InsertBeforeStatementQuickFix.assignsResultBeforeFetch( + source, source.indexOf("log("), source.indexOf("$db->fetchArray"), "$result")); + } } diff --git a/src/test/java/org/xoops/support/inspections/XoopsRootPathGuardPolicyTest.java b/src/test/java/org/xoops/support/inspections/XoopsRootPathGuardPolicyTest.java index e5d944d..d5c1df2 100644 --- a/src/test/java/org/xoops/support/inspections/XoopsRootPathGuardPolicyTest.java +++ b/src/test/java/org/xoops/support/inspections/XoopsRootPathGuardPolicyTest.java @@ -100,6 +100,32 @@ public void htmlLeadingIncludeStillRequiresGuardButDeclinesInsert() { assertTrue(XoopsRootPathGuardPolicy.canInsertGuard("plain text, no php\n")); } + @Test + public void echoOpenTagAloneStillRequiresGuard() { + String source = "\n"; + assertTrue(XoopsRootPathGuardPolicy.requiresGuard( + "C:/site/htdocs/modules/demo/include/view.php", + source + )); + assertEquals(-1, XoopsRootPathGuardPolicy.insertOffset(source)); + assertTrue(XoopsRootPathGuardPolicy.canInsertGuard(source)); + } + + @Test + public void unmatchedHtmlApostropheDoesNotHidePhpBody() { + String source = """ +
    + 'commented.tpl' """; Set names = XoopsUnregisteredTemplateInspection.registeredTemplates(manifest); - assertTrue(names.contains("demo_index.tpl")); - assertTrue(names.contains("demo_block.tpl")); - assertFalse(names.contains("commented.tpl")); + assertTrue(names.contains("templates/demo_index.tpl")); + assertTrue(names.contains("templates/demo_block.tpl")); + assertFalse(names.contains("templates/commented.tpl")); } @Test @@ -34,8 +34,8 @@ public void commentMarkersInsideStringsAreNotComments() { // 'file' => 'commented.tpl' """; Set names = XoopsUnregisteredTemplateInspection.registeredTemplates(manifest); - assertTrue(names.contains("listed.tpl")); - assertFalse(names.contains("commented.tpl")); + assertTrue(names.contains("templates/listed.tpl")); + assertFalse(names.contains("templates/commented.tpl")); } @Test @@ -46,8 +46,8 @@ public void assignmentSyntaxRegistersTemplatesToo() { $modversion['templates'][2]['file'] = "demo_list.tpl"; """; Set names = XoopsUnregisteredTemplateInspection.registeredTemplates(manifest); - assertTrue(names.contains("demo_block.tpl")); - assertTrue(names.contains("demo_list.tpl")); + assertTrue(names.contains("templates/blocks/demo_block.tpl")); + assertTrue(names.contains("templates/demo_list.tpl")); } @Test @@ -58,12 +58,12 @@ public void moduleRootPrefixedRegistrationsMatchRelativeNames() { $modversion['templates'][] = ['template' => 'blocks/rooted_block.tpl']; """; Set names = XoopsUnregisteredTemplateInspection.registeredTemplates(manifest); - assertTrue(names.contains("rooted.tpl")); - assertTrue(names.contains("rooted_block.tpl")); + assertTrue(names.contains("templates/rooted.tpl")); + assertTrue(names.contains("blocks/rooted_block.tpl")); assertTrue(names.contains("templates/rooted.tpl")); Set full = XoopsUnregisteredTemplateInspection.registeredTemplates( " 'templates/blocks/deep_block.tpl'];"); - assertTrue(full.contains("blocks/deep_block.tpl")); - assertTrue(full.contains("deep_block.tpl")); + assertTrue(full.contains("templates/blocks/deep_block.tpl")); + assertFalse(full.contains("deep_block.tpl")); } } diff --git a/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java b/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java index ac1bcc1..b51c42b 100644 --- a/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java +++ b/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java @@ -13,6 +13,30 @@ public final class XoopsProjectScannerTest { + + @Test + public void pageTemplateDoesNotSatisfyBlockRegistration() throws Exception { + Path moduleRoot = Files.createTempDirectory("xoops-template-kind"); + try { + Files.writeString(moduleRoot.resolve("xoops_version.php"), + " findings = new ArrayList<>(); + XoopsProjectScanner.checkRegisteredTemplates(moduleRoot, findings); + assertTrue(findings.stream().anyMatch(f -> "MISSING_REGISTERED_TEMPLATE".equals(f.kind()))); + assertTrue(findings.stream().anyMatch(f -> "UNREGISTERED_TEMPLATE".equals(f.kind()))); + Files.createDirectories(moduleRoot.resolve("templates/blocks")); + Files.writeString(moduleRoot.resolve("templates/blocks/shared.tpl"), "block"); + findings.clear(); + XoopsProjectScanner.checkRegisteredTemplates(moduleRoot, findings); + assertFalse(findings.stream().anyMatch(f -> "MISSING_REGISTERED_TEMPLATE".equals(f.kind()))); + assertEquals(1, findings.stream().filter(f -> "UNREGISTERED_TEMPLATE".equals(f.kind())).count()); + } finally { + deleteRecursively(moduleRoot); + } + } + @Test public void versionPatternsPinTheDocumentedCoreLines() { assertTrue(XoopsProjectScanner.VERSION_25.matcher("XOOPS 2.5.11").find()); @@ -25,6 +49,23 @@ public void versionPatternsPinTheDocumentedCoreLines() { assertFalse(XoopsProjectScanner.VERSION_40.matcher("build 14.0").find()); } + @Test + public void coreVersionSettingOverridesAutoDetection() { + assertEquals(CoreVersion.XOOPS_25, XoopsProjectScanner.coreVersionFromSetting("2.5")); + assertEquals(CoreVersion.XOOPS_27, XoopsProjectScanner.coreVersionFromSetting("2.7")); + assertEquals(CoreVersion.XOOPS_40, XoopsProjectScanner.coreVersionFromSetting("4.0")); + assertEquals(null, XoopsProjectScanner.coreVersionFromSetting("Auto")); + Path ignored = Path.of("."); + assertEquals( + CoreVersion.XOOPS_27, + XoopsProjectScanner.resolveScanCoreVersion("2.7", true, ignored, ignored) + ); + assertEquals( + CoreVersion.MODULE_ONLY, + XoopsProjectScanner.resolveScanCoreVersion("Auto", true, ignored, ignored) + ); + } + @Test public void manifestDirnameAndRegisteredTemplateRegexes() { String manifest = """ From fe317891a0ea99c6c6954d4305aeca8c701751ed Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 21 Sep 2026 22:00:10 -0400 Subject: [PATCH 17/20] fix: address guard, template, and language cache review findings --- .../XoopsLanguageConstantsCache.java | 38 ++++++--- .../inspections/RegisterTemplateQuickFix.java | 5 +- .../inspections/XoopsManifestTemplates.java | 8 +- .../XoopsResultSetGuardInspection.java | 17 ++++ .../LanguageCacheRegressionTest.java | 82 +++++++++++++++++++ .../inspections/ReviewRegressionTest.java | 46 +++++++++++ 6 files changed, 180 insertions(+), 16 deletions(-) create mode 100644 src/test/java/org/xoops/support/completion/LanguageCacheRegressionTest.java create mode 100644 src/test/java/org/xoops/support/inspections/ReviewRegressionTest.java diff --git a/src/main/java/org/xoops/support/completion/XoopsLanguageConstantsCache.java b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantsCache.java index 151aff4..ec7cf41 100644 --- a/src/main/java/org/xoops/support/completion/XoopsLanguageConstantsCache.java +++ b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantsCache.java @@ -6,10 +6,12 @@ import com.intellij.openapi.project.IndexNotReadyException; import com.intellij.openapi.project.Project; import com.intellij.openapi.util.Key; +import com.intellij.openapi.util.SimpleModificationTracker; import com.intellij.openapi.vfs.AsyncFileListener; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.openapi.vfs.VirtualFileManager; import com.intellij.openapi.vfs.newvfs.events.VFileEvent; +import com.intellij.openapi.vfs.newvfs.events.VFileMoveEvent; import com.intellij.psi.PsiElement; import com.intellij.psi.PsiFile; import com.intellij.psi.PsiManager; @@ -18,7 +20,6 @@ import com.intellij.psi.util.CachedValue; import com.intellij.psi.util.CachedValueProvider; import com.intellij.psi.util.CachedValuesManager; -import com.intellij.psi.util.PsiModificationTracker; import com.intellij.util.concurrency.annotations.RequiresReadLock; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -35,7 +36,7 @@ /** * Project-level cache of XOOPS language constants from every PHP file under - * {@code language/}. Tied to {@link PsiModificationTracker#MODIFICATION_COUNT}; + * {@code language/}. Depends on language PSI files and relevant VFS changes; * VFS listener parent is this service (not the project) so plugin unload disposes it. */ @Service(Service.Level.PROJECT) @@ -47,6 +48,7 @@ public final class XoopsLanguageConstantsCache implements Disposable { private static final int MAX_CONSTANTS = 5000; private final Project project; + private final SimpleModificationTracker languageChanges = new SimpleModificationTracker(); private volatile boolean disposed; public XoopsLanguageConstantsCache(@NotNull Project project) { @@ -59,7 +61,15 @@ public XoopsLanguageConstantsCache(@NotNull Project project) { boolean hit = false; for (VFileEvent event : events) { VirtualFile file = event.getFile(); - if (file != null && XoopsLanguageConstantParser.isLanguagePath(file.getPath())) { + // ponytail: directory events conservatively invalidate; subtree tracking only if needed. + if (file != null && (file.isDirectory() + || XoopsLanguageConstantParser.isLanguagePath(file.getPath()))) { + hit = true; + break; + } + if (event instanceof VFileMoveEvent move + && XoopsLanguageConstantParser.isLanguagePath( + move.getNewParent().getPath() + "/" + move.getFile().getName())) { hit = true; break; } @@ -91,7 +101,7 @@ public void afterVfsChange() { public void invalidate() { if (!project.isDisposed()) { - project.putUserData(CACHE_KEY, null); + languageChanges.incModificationCount(); } } @@ -135,7 +145,7 @@ public void invalidate() { @Override public void dispose() { disposed = true; - invalidate(); + project.putUserData(CACHE_KEY, null); } private @Nullable Index getIndex() { @@ -150,10 +160,7 @@ public void dispose() { CachedValue cached = project.getUserData(CACHE_KEY); if (cached == null) { cached = CachedValuesManager.getManager(project).createCachedValue( - () -> CachedValueProvider.Result.create( - collectUnderReadLock(), - PsiModificationTracker.MODIFICATION_COUNT - ), + this::collectUnderReadLock, false ); project.putUserData(CACHE_KEY, cached); @@ -166,7 +173,9 @@ public void dispose() { } @RequiresReadLock - private @NotNull Index collectUnderReadLock() { + private @NotNull CachedValueProvider.Result collectUnderReadLock() { + List dependencies = new ArrayList<>(); + dependencies.add(languageChanges); Set names = new LinkedHashSet<>(); Map> defs = new LinkedHashMap<>(); PsiManager psiManager = PsiManager.getInstance(project); @@ -181,6 +190,8 @@ public void dispose() { if (psi == null) { continue; } + // PSI dependencies include committed editor changes, even before saving to disk. + dependencies.add(psi); for (XoopsLanguageConstantParser.Occurrence occ : XoopsLanguageConstantParser.parse(psi.getText())) { if (!names.contains(occ.name()) && names.size() >= MAX_CONSTANTS) { continue; // cap new names; definitions of known names are still recorded @@ -189,7 +200,8 @@ public void dispose() { defs.computeIfAbsent(occ.name(), k -> new ArrayList<>()).add(new Def(vf, occ.offset())); } } - return new Index(Collections.unmodifiableSet(names), Map.copyOf(defs)); + return CachedValueProvider.Result.create( + new Index(Collections.unmodifiableSet(names), Map.copyOf(defs)), dependencies.toArray()); } /** Same module and /english/ first, then same module, then any /english/, then the first. */ @@ -198,7 +210,7 @@ public void dispose() { Def english = null; for (Def d : defs) { String path = d.file.getPath().replace('\\', '/').toLowerCase(Locale.ROOT); - boolean inModule = moduleSegment != null && path.contains(moduleSegment); + boolean inModule = moduleSegment != null && moduleSegment.equals(moduleSegment(path)); boolean isEnglish = path.contains("/english/"); if (inModule && isEnglish) { return d; @@ -216,7 +228,7 @@ public void dispose() { /** {@code /modules//} of a path, lower-case, or null when not under modules/. */ static @Nullable String moduleSegment(@NotNull String path) { String p = path.replace('\\', '/').toLowerCase(Locale.ROOT); - int i = p.indexOf("/modules/"); + int i = p.lastIndexOf("/modules/"); if (i < 0) { return null; } diff --git a/src/main/java/org/xoops/support/inspections/RegisterTemplateQuickFix.java b/src/main/java/org/xoops/support/inspections/RegisterTemplateQuickFix.java index 1607982..f23741f 100644 --- a/src/main/java/org/xoops/support/inspections/RegisterTemplateQuickFix.java +++ b/src/main/java/org/xoops/support/inspections/RegisterTemplateQuickFix.java @@ -60,8 +60,9 @@ public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descri + " 'description' => '',\n" + "];\n"; int insertAt = text.length(); - int closePhp = text.lastIndexOf("?>"); - if (closePhp >= 0 && text.substring(closePhp).trim().equals("?>")) { + String code = PhpTextUtil.maskCommentsAndStrings(text); + int closePhp = code.lastIndexOf("?>"); + if (closePhp >= 0 && code.lastIndexOf(" find(@NotNull String commentMasked) { + String code = PhpTextUtil.maskCommentsAndStrings(commentMasked); List out = new ArrayList<>(); Set seen = new LinkedHashSet<>(); Matcher stmt = MODVERSION_TEMPLATES.matcher(commentMasked); int searchFrom = 0; while (stmt.find(searchFrom)) { + if (code.charAt(stmt.start()) != '$') { + searchFrom = stmt.end(); + continue; + } int end = statementEnd(commentMasked, stmt.end()); Section section = "blocks".equalsIgnoreCase(stmt.group(1)) ? Section.BLOCKS : Section.TEMPLATES; Matcher m = FILE_OR_TEMPLATE.matcher(commentMasked); m.region(stmt.end(), end); while (m.find()) { - if (seen.add(m.start(1))) { + int assignment = commentMasked.indexOf('=', m.start()); + if (code.charAt(assignment) == '=' && seen.add(m.start(1))) { out.add(new Registration(m.group(1).replace('\\', '/'), m.start(1), section)); } } diff --git a/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java b/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java index e0cc125..8178483 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java +++ b/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java @@ -469,6 +469,9 @@ private static boolean isWordAt(@NotNull String s, int i, @NotNull String word) * parenthesized forms). */ private static boolean isSafePositiveGuardCondition(@NotNull String cond, @NotNull String resultVar) { + if (hasUnprovenPolarity(cond)) { + return false; + } if (!conditionMentionsIsResultSet(cond, resultVar)) { return false; } @@ -487,6 +490,9 @@ private static boolean isSafePositiveGuardCondition(@NotNull String cond, @NotNu * parenthesized forms) where exit is conditional. */ private static boolean isSafeEarlyExitCondition(@NotNull String cond, @NotNull String resultVar) { + if (hasUnprovenPolarity(cond)) { + return false; + } if (!conditionNegatesIsResultSet(cond, resultVar)) { return false; } @@ -495,6 +501,17 @@ private static boolean isSafeEarlyExitCondition(@NotNull String cond, @NotNull S return !hasBoolOpAnywhere(cond, false) && !hasXorAnywhere(cond); } + private static boolean hasUnprovenPolarity(@NotNull String condition) { + // ponytail: comparisons and ternaries need expression analysis; report rather than guess. + String operators = condition.replace("->", ""); + for (int i = 0; i < operators.length(); i++) { + if ("=<>?:".indexOf(operators.charAt(i)) >= 0) { + return true; + } + } + return false; + } + private static boolean isInsidePositiveIsResultSetGuard( @NotNull String code, int fetchOffset, diff --git a/src/test/java/org/xoops/support/completion/LanguageCacheRegressionTest.java b/src/test/java/org/xoops/support/completion/LanguageCacheRegressionTest.java new file mode 100644 index 0000000..54f3e6a --- /dev/null +++ b/src/test/java/org/xoops/support/completion/LanguageCacheRegressionTest.java @@ -0,0 +1,82 @@ +package org.xoops.support.completion; + +import com.intellij.openapi.command.WriteCommandAction; +import com.intellij.psi.PsiDocumentManager; +import com.intellij.testFramework.fixtures.BasePlatformTestCase; + +public final class LanguageCacheRegressionTest extends BasePlatformTestCase { + public void testAncestorModulesDirectoryDoesNotChooseModule() { + assertEquals("/modules/news/", XoopsLanguageConstantsCache.moduleSegment( + "/srv/modules/work/site/modules/news/file.php")); + } + + public void testCacheIgnoresUnrelatedEditsButTracksUnsavedLanguageChanges() { + var language = myFixture.addFileToProject("modules/news/language/english/main.php", + " { + manager.getDocument(unrelated).setText(" { + manager.getDocument(language).setText(" { + try { + moved.getVirtualFile().move(this, language.getVirtualFile().getParent()); + } catch (java.io.IOException e) { + throw new RuntimeException(e); + } + }); + assertTrue(cache.getConstants().contains("_MI_MOVED")); + WriteCommandAction.runWriteCommandAction(getProject(), () -> { + try { + language.getVirtualFile().getParent().getParent().rename(this, "translations"); + } catch (java.io.IOException e) { + throw new RuntimeException(e); + } + }); + assertFalse(cache.getConstants().contains("_MI_MOVED")); + } + public void testMoveIntoEmptyLanguageDirectoryInvalidatesEmptyCache() throws Exception { + var directory = myFixture.getTempDirFixture().findOrCreateDir("modules/news/language/english"); + var moved = myFixture.addFileToProject("modules/news/moved.php", " { + try { + moved.getVirtualFile().move(this, directory); + } catch (java.io.IOException e) { + throw new RuntimeException(e); + } + }); + assertTrue(cache.getConstants().contains("_MI_FIRST")); + } +} diff --git a/src/test/java/org/xoops/support/inspections/ReviewRegressionTest.java b/src/test/java/org/xoops/support/inspections/ReviewRegressionTest.java new file mode 100644 index 0000000..bc668eb --- /dev/null +++ b/src/test/java/org/xoops/support/inspections/ReviewRegressionTest.java @@ -0,0 +1,46 @@ +package org.xoops.support.inspections; + +import com.intellij.codeInspection.InspectionManager; +import com.intellij.codeInspection.ProblemHighlightType; +import com.intellij.openapi.command.WriteCommandAction; +import com.intellij.psi.PsiDocumentManager; +import com.intellij.testFramework.fixtures.BasePlatformTestCase; + +public final class ReviewRegressionTest extends BasePlatformTestCase { + public void testReversedPredicatesDoNotGuardFetches() { + for (String condition : new String[]{"$db->isResultSet($result) === false", + "$db->isResultSet($result) ? false : true"}) { + assertEquals(condition, 1, XoopsResultSetGuardInspection.unguardedFetchOffsets( + "fetchArray($result); }").size()); + } + assertEquals(1, XoopsResultSetGuardInspection.unguardedFetchOffsets( + "isResultSet($result) === false) return; $db->fetchArray($result);").size()); + } + + public void testRegistrationExamplesInStringsAreIgnored() { + String php = """ + 'real.tpl', + 'description' => "Example: 'file' => 'ghost.tpl'"]; + $help = '$modversion["templates"][] = ["file" => "fake.tpl"];'; + """; + assertEquals(java.util.Set.of("templates/real.tpl"), XoopsManifestTemplates.keys(php)); + } + + public void testRegistrationStaysInsidePhp() { + int module = 0; + for (String suffix : new String[]{"?>

    HTML ?> suffix

    ", + "$note = '?>';", "?>HTML';"}) { + var manifest = myFixture.addFileToProject("module" + module + "/xoops_version.php", " + new RegisterTemplateQuickFix("new.tpl").applyFix(getProject(), descriptor)); + PsiDocumentManager.getInstance(getProject()).commitAllDocuments(); + assertEquals(suffix, java.util.Set.of("templates/new.tpl"), + XoopsManifestTemplates.keys(manifest.getText())); + } + } +} From ee17ccaabec2adb18980b8db4db0231e801f9634 Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 21 Sep 2026 23:01:06 -0400 Subject: [PATCH 18/20] fix: handle nested guards, language renames, and template casing --- TUTORIAL.md | 2 +- .../XoopsLanguageConstantsCache.java | 6 ++ .../inspections/XoopsManifestTemplates.java | 7 +- ...psMissingRegisteredTemplateInspection.java | 23 +++--- .../XoopsResultSetGuardInspection.java | 44 ++++++++++- .../inspections/XoopsTemplatePaths.java | 77 +++++++++++++++++++ .../support/scanner/XoopsProjectScanner.java | 15 +++- .../LanguageCacheRegressionTest.java | 10 +++ .../inspections/ReviewRegressionTest.java | 16 ++++ .../XoopsResultSetGuardInspectionTest.java | 32 ++++++++ .../scanner/XoopsProjectScannerTest.java | 36 +++++++++ 11 files changed, 244 insertions(+), 24 deletions(-) create mode 100644 src/main/java/org/xoops/support/inspections/XoopsTemplatePaths.java diff --git a/TUTORIAL.md b/TUTORIAL.md index 28aecb9..ef2adea 100644 --- a/TUTORIAL.md +++ b/TUTORIAL.md @@ -241,7 +241,7 @@ A `.tpl` file under the module `templates/` or `blocks/` directory is not listed **Why.** The `db:` template resource resolves through the `tplfile` table, and only registered templates get a row. An unregistered `.tpl` renders fine from the file system on a stock template set during development, then breaks on a site whose template set was imported, or on the first module update that resyncs templates. Registering it at creation time removes the surprise. -**Matching.** A manifest entry may be spelled relative to `templates/` (`demo_index.tpl`, the XOOPS convention) or from the module root (`templates/demo_index.tpl`); both are accepted. Names are compared case-insensitively. +**Matching.** A manifest entry may be spelled relative to `templates/` (`demo_index.tpl`, the XOOPS convention) or from the module root (`templates/demo_index.tpl`); both are accepted. Case-insensitive matching identifies the registration, but filename and directory spelling must match the disk exactly. The missing-template inspection and scanner report casing differences as a case mismatch, not a missing or unregistered template. **Quick-fix** appends this at the end of the manifest (before a trailing `?>` if there is one), unless the name is already quoted somewhere in the file: diff --git a/src/main/java/org/xoops/support/completion/XoopsLanguageConstantsCache.java b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantsCache.java index ec7cf41..23d4e2a 100644 --- a/src/main/java/org/xoops/support/completion/XoopsLanguageConstantsCache.java +++ b/src/main/java/org/xoops/support/completion/XoopsLanguageConstantsCache.java @@ -12,6 +12,7 @@ import com.intellij.openapi.vfs.VirtualFileManager; import com.intellij.openapi.vfs.newvfs.events.VFileEvent; import com.intellij.openapi.vfs.newvfs.events.VFileMoveEvent; +import com.intellij.openapi.vfs.newvfs.events.VFilePropertyChangeEvent; import com.intellij.psi.PsiElement; import com.intellij.psi.PsiFile; import com.intellij.psi.PsiManager; @@ -73,6 +74,11 @@ public XoopsLanguageConstantsCache(@NotNull Project project) { hit = true; break; } + if (event instanceof VFilePropertyChangeEvent rename + && XoopsLanguageConstantParser.isLanguagePath(rename.getNewPath())) { + hit = true; + break; + } String path = event.getPath(); if (path != null && XoopsLanguageConstantParser.isLanguagePath(path)) { hit = true; diff --git a/src/main/java/org/xoops/support/inspections/XoopsManifestTemplates.java b/src/main/java/org/xoops/support/inspections/XoopsManifestTemplates.java index 99273ed..64841d4 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsManifestTemplates.java +++ b/src/main/java/org/xoops/support/inspections/XoopsManifestTemplates.java @@ -3,6 +3,7 @@ import org.jetbrains.annotations.NotNull; import java.util.ArrayList; +import java.util.Arrays; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; @@ -90,10 +91,8 @@ private XoopsManifestTemplates() { * Bare block names go under {@code templates/blocks/}; already-prefixed names stay as spelled. */ public static @NotNull String diskPath(@NotNull String name, boolean block) { - String n = name.replace('\\', '/'); - while (n.startsWith("/")) { - n = n.substring(1); - } + String n = String.join("/", Arrays.stream(name.replace('\\', '/').split("/")) + .filter(part -> !part.isEmpty() && !part.equals(".")).toList()); if (n.startsWith("templates/") || n.startsWith("blocks/")) { return n; } diff --git a/src/main/java/org/xoops/support/inspections/XoopsMissingRegisteredTemplateInspection.java b/src/main/java/org/xoops/support/inspections/XoopsMissingRegisteredTemplateInspection.java index 4b35852..adb00e6 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsMissingRegisteredTemplateInspection.java +++ b/src/main/java/org/xoops/support/inspections/XoopsMissingRegisteredTemplateInspection.java @@ -37,9 +37,15 @@ public void visitFile(@NotNull PsiFile file) { String code = PhpTextUtil.maskCommentsOnly(text); for (XoopsManifestTemplates.Registration reg : XoopsManifestTemplates.find(code)) { String template = reg.name(); - boolean exists = childExists(moduleRoot, - XoopsManifestTemplates.diskPath(template, reg.block())); - if (!exists) { + String expected = XoopsManifestTemplates.diskPath(template, reg.block()); + String actual = XoopsTemplatePaths.existingPath(moduleRoot, expected); + if (actual != null && !expected.equals(actual)) { + PsiElement leaf = PhpTextUtil.leafAt(file, reg.nameOffset()); + if (leaf != null) { + holder.registerProblem(leaf, + "XOOPS: template filename case mismatch: " + expected + " (on disk: " + actual + ")"); + } + } else if (actual == null) { PsiElement leaf = PhpTextUtil.leafAt(file, reg.nameOffset()); if (leaf != null) { holder.registerProblem( @@ -54,15 +60,4 @@ public void visitFile(@NotNull PsiFile file) { }; } - private static boolean childExists(VirtualFile root, String relative) { - String[] parts = relative.split("/"); - VirtualFile cur = root; - for (String part : parts) { - if (cur == null) { - return false; - } - cur = cur.findChild(part); - } - return cur != null && !cur.isDirectory(); - } } diff --git a/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java b/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java index 8178483..4678aa7 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java +++ b/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java @@ -64,6 +64,10 @@ public final class XoopsResultSetGuardInspection extends LocalInspectionTool { "(?is)!\\s*\\(?\\s*(\\$[A-Za-z_][\\w]*)\\s*instanceof" ); + private static final Pattern INSTANCEOF_RESULT = Pattern.compile( + "(?is)\\$[A-Za-z_][\\w]*\\s+instanceof\\s+\\\\?mysqli_result" + ); + /** Single exit statement only (no nested control structure). */ private static final Pattern SIMPLE_EARLY_EXIT = Pattern.compile( "(?is)^\\s*(return|throw|exit|die|break|continue)\\b[^;{]*;?\\s*$" @@ -162,7 +166,7 @@ private static boolean hasDominatingEarlyExit( ) { for (int i = before.size() - 1; i >= 0; i--) { IfCond ic = before.get(i); - if (ic.ifEnd > fetchOffset || ic.chained) { + if (ic.ifEnd > fetchOffset || ic.chained || !startsAtStatementBoundary(code, ic.ifStart)) { // An elseif / else-if branch is skipped whenever an earlier branch matched, // so its early exit proves nothing about the fall-through path. continue; @@ -195,6 +199,17 @@ && bodyIsSimpleEarlyExit(code, ic) return out; } + private static boolean startsAtStatementBoundary(@NotNull String code, int start) { + int previous = start - 1; + while (previous >= 0 && Character.isWhitespace(code.charAt(previous))) { + previous--; + } + // ponytail: only standalone statements prove dominance; labels/alternative syntax need PSI. + return previous < 0 || ";{}".indexOf(code.charAt(previous)) >= 0 + || previous >= 4 && code.regionMatches(true, previous - 4, "= 1 && code.regionMatches(previous - 1, "", ""); + // ponytail: only simple negated atoms are proven; other expression forms need PSI. + String operators = condition; for (int i = 0; i < operators.length(); i++) { + if (operators.charAt(i) == '>' && i > 0 && operators.charAt(i - 1) == '-') { + continue; + } if ("=<>?:".indexOf(operators.charAt(i)) >= 0) { return true; } + if (operators.charAt(i) == '!') { + int next = i + 1; + while (next < operators.length() && Character.isWhitespace(operators.charAt(next))) { + next++; + } + if (next < operators.length() && operators.charAt(next) == '!') { + return true; + } + if (next < operators.length() && operators.charAt(next) == '(') { + int close = matchingCloseParen(operators, next); + if (close < 0) { + return true; + } + String group = operators.substring(next + 1, close); + if (!NEG_IS_RESULT_SET.matcher(operators.substring(i, close + 1)).matches() + && !INSTANCEOF_RESULT.matcher(group.strip()).matches()) { + return true; + } + } + } } return false; } diff --git a/src/main/java/org/xoops/support/inspections/XoopsTemplatePaths.java b/src/main/java/org/xoops/support/inspections/XoopsTemplatePaths.java new file mode 100644 index 0000000..d48827a --- /dev/null +++ b/src/main/java/org/xoops/support/inspections/XoopsTemplatePaths.java @@ -0,0 +1,77 @@ +package org.xoops.support.inspections; + +import com.intellij.openapi.vfs.VirtualFile; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Stream; + +/** Resolves registered template paths while retaining their actual disk spelling. */ +public final class XoopsTemplatePaths { + private XoopsTemplatePaths() { + } + + /** Actual module-relative path, preferring exact spelling; null when no file matches. */ + public static @Nullable String existingPath(@NotNull VirtualFile root, @NotNull String relative) { + VirtualFile current = root; + List actual = new ArrayList<>(); + for (String part : relative.split("/")) { + if (!current.isDirectory()) { + return null; + } + String name = matchingName(Arrays.stream(current.getChildren()).map(VirtualFile::getName).toList(), part); + if (name == null) { + return null; + } + actual.add(name); + current = current.findChild(name); + if (current == null) { + return null; + } + } + return current.isDirectory() ? null : String.join("/", actual); + } + + /** Scanner equivalent of the VFS lookup; I/O errors remain distinguishable from missing files. */ + public static @Nullable String existingPath(@NotNull Path root, @NotNull String relative) throws IOException { + Path current = root; + List actual = new ArrayList<>(); + for (String part : relative.split("/")) { + if (!Files.isDirectory(current)) { + return null; + } + String name; + try (Stream children = Files.list(current)) { + name = matchingName(children.map(p -> p.getFileName().toString()).toList(), part); + } + if (name == null) { + return null; + } + actual.add(name); + current = current.resolve(name); + } + return Files.isRegularFile(current) ? String.join("/", actual) : null; + } + + private static @Nullable String matchingName(List names, String requested) { + if (requested.isEmpty() || requested.equals(".") || requested.equals("..")) { + return null; + } + String fallback = null; + for (String name : names) { + if (name.equals(requested)) { + return name; + } + if (name.equalsIgnoreCase(requested)) { + fallback = name; + } + } + return fallback; + } +} diff --git a/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java b/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java index 5995d01..82af773 100644 --- a/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java +++ b/src/main/java/org/xoops/support/scanner/XoopsProjectScanner.java @@ -5,6 +5,7 @@ import org.jetbrains.annotations.Nullable; import org.xoops.support.inspections.PhpTextUtil; import org.xoops.support.inspections.XoopsManifestTemplates; +import org.xoops.support.inspections.XoopsTemplatePaths; import java.io.IOException; import java.io.UncheckedIOException; @@ -368,8 +369,18 @@ static void checkRegisteredTemplates(Path moduleRoot, List finding String template = reg.name(); String relative = XoopsManifestTemplates.diskPath(template, reg.block()); registered.add(relative.toLowerCase(Locale.ROOT)); - boolean exists = Files.isRegularFile(moduleRoot.resolve(relative)); - if (!exists) { + String actual; + try { + actual = XoopsTemplatePaths.existingPath(moduleRoot, relative); + } catch (IOException | UncheckedIOException exception) { + findings.add(scanError(manifest, "Could not locate template: " + exception.getMessage())); + continue; + } + if (actual != null && !relative.equals(actual)) { + findings.add(new XoopsFinding( + "TEMPLATE_CASE_MISMATCH", manifest, lineAt(content, reg.nameOffset()), + "Template filename case mismatch: " + relative + " (on disk: " + actual + ")")); + } else if (actual == null) { findings.add(new XoopsFinding( "MISSING_REGISTERED_TEMPLATE", manifest, diff --git a/src/test/java/org/xoops/support/completion/LanguageCacheRegressionTest.java b/src/test/java/org/xoops/support/completion/LanguageCacheRegressionTest.java index 54f3e6a..412ec83 100644 --- a/src/test/java/org/xoops/support/completion/LanguageCacheRegressionTest.java +++ b/src/test/java/org/xoops/support/completion/LanguageCacheRegressionTest.java @@ -79,4 +79,14 @@ public void testMoveIntoEmptyLanguageDirectoryInvalidatesEmptyCache() throws Exc }); assertTrue(cache.getConstants().contains("_MI_FIRST")); } + public void testRenameIntoPhpInvalidatesCache() { + var file = myFixture.addFileToProject("modules/news/language/english/main.txt", " { + try { file.getVirtualFile().rename(this, "main.php"); } + catch (java.io.IOException e) { throw new RuntimeException(e); } + }); + assertTrue(cache.getConstants().contains("_MI_RENAMED")); + } } diff --git a/src/test/java/org/xoops/support/inspections/ReviewRegressionTest.java b/src/test/java/org/xoops/support/inspections/ReviewRegressionTest.java index bc668eb..48fa8a2 100644 --- a/src/test/java/org/xoops/support/inspections/ReviewRegressionTest.java +++ b/src/test/java/org/xoops/support/inspections/ReviewRegressionTest.java @@ -43,4 +43,20 @@ public void testRegistrationStaysInsidePhp() { XoopsManifestTemplates.keys(manifest.getText())); } } + public void testTemplateCaseMismatchIsReportedWithoutCreateFix() { + var manifest = myFixture.addFileToProject("case-module/xoops_version.php", + " 'Admin/Foo.tpl'];"); + var template = myFixture.addFileToProject("case-module/templates/admin/foo.tpl", "template"); + var holder = new com.intellij.codeInspection.ProblemsHolder( + InspectionManager.getInstance(getProject()), manifest, false); + new XoopsMissingRegisteredTemplateInspection().buildVisitor(holder, false).visitFile(manifest); + assertEquals(1, holder.getResults().size()); + assertTrue(holder.getResults().get(0).getDescriptionTemplate().contains("case mismatch")); + var fixes = holder.getResults().get(0).getFixes(); + assertTrue(fixes == null || fixes.length == 0); + var inverse = new com.intellij.codeInspection.ProblemsHolder( + InspectionManager.getInstance(getProject()), template, false); + new XoopsUnregisteredTemplateInspection().buildVisitor(inverse, false).visitFile(template); + assertTrue(inverse.getResults().isEmpty()); + } } diff --git a/src/test/java/org/xoops/support/inspections/XoopsResultSetGuardInspectionTest.java b/src/test/java/org/xoops/support/inspections/XoopsResultSetGuardInspectionTest.java index c67f577..7a08b70 100644 --- a/src/test/java/org/xoops/support/inspections/XoopsResultSetGuardInspectionTest.java +++ b/src/test/java/org/xoops/support/inspections/XoopsResultSetGuardInspectionTest.java @@ -242,4 +242,36 @@ public void assignmentCheckMasksWholePhpDocumentBeforeSlicing() { assertFalse(InsertBeforeStatementQuickFix.assignsResultBeforeFetch( source, source.indexOf("log("), source.indexOf("$db->fetchArray"), "$result")); } + @Test + public void nestedNegationsDoNotProveAnEarlyExit() { + for (String condition : new String[]{"!!$db->isResultSet($result)", + "!(!$db->isResultSet($result))", "!($other || !$db->isResultSet($result))", + "!!($result instanceof \\mysqli_result)"}) { + assertEquals(condition, 1, XoopsResultSetGuardInspection.unguardedFetchOffsets( + "fetchArray($result);").size()); + } + } + + @Test + public void unbracedControlBodiesDoNotDominateLaterFetches() { + for (String prefix : new String[]{"if ($enabled)", "while ($enabled)", + "foreach ($items as $item)", "for ($i = 0; $i < 2; $i++)"}) { + String source = "isResultSet($result)) return; $db->fetchArray($result);"; + assertEquals(prefix, 1, XoopsResultSetGuardInspection.unguardedFetchOffsets(source).size()); + assertFalse(prefix, XoopsResultSetGuardInspection.isFetchGuardedAt(source, + source.indexOf("$db->fetchArray"), "$result")); + } + assertTrue(XoopsResultSetGuardInspection.unguardedFetchOffsets( + "isResultSet($result)) return; $db->fetchArray($result); }").isEmpty()); + } + @Test + public void unrecognizedNegatedGroupsDoNotProvePositiveGuards() { + for (String condition : new String[]{"!(($db->isResultSet($result)))", + "!($other || $db->isResultSet($result))"}) { + assertEquals(condition, 1, XoopsResultSetGuardInspection.unguardedFetchOffsets( + "fetchArray($result); }").size()); + } + assertTrue(XoopsResultSetGuardInspection.unguardedFetchOffsets( + "isResultSet($result))) return; $db->fetchArray($result);").isEmpty()); + } } diff --git a/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java b/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java index b51c42b..d3442c3 100644 --- a/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java +++ b/src/test/java/org/xoops/support/scanner/XoopsProjectScannerTest.java @@ -276,4 +276,40 @@ private static void deleteRecursively(Path root) throws Exception { }); } } + @Test + public void templateCaseMismatchIsNeitherMissingNorUnregistered() throws Exception { + Path root = Files.createTempDirectory("xoops-template-case"); + try { + Files.createDirectories(root.resolve("templates/admin")); + Files.writeString(root.resolve("templates/admin/foo.tpl"), "template"); + Files.writeString(root.resolve("xoops_version.php"), + " 'Admin/Foo.tpl'];"); + List findings = new ArrayList<>(); + XoopsProjectScanner.checkRegisteredTemplates(root, findings); + assertEquals(1, findings.size()); + assertEquals("TEMPLATE_CASE_MISMATCH", findings.get(0).kind()); + Files.writeString(root.resolve("xoops_version.php"), + " 'admin/foo.tpl'];"); + findings.clear(); + XoopsProjectScanner.checkRegisteredTemplates(root, findings); + assertTrue(findings.isEmpty()); + } finally { + deleteRecursively(root); + } + } + @Test + public void harmlessPathComponentsDoNotCreateTemplateFindings() throws Exception { + Path root = Files.createTempDirectory("xoops-template-dots"); + try { + Files.createDirectories(root.resolve("templates/admin")); + Files.writeString(root.resolve("templates/admin/foo.tpl"), "template"); + Files.writeString(root.resolve("xoops_version.php"), + " './admin//./foo.tpl'];"); + List findings = new ArrayList<>(); + XoopsProjectScanner.checkRegisteredTemplates(root, findings); + assertTrue(findings.toString(), findings.isEmpty()); + } finally { + deleteRecursively(root); + } + } } From aceff7c827b633b2bd489e86777e22de4a7379eb Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Mon, 21 Sep 2026 23:39:30 -0400 Subject: [PATCH 19/20] fix: preserve guard scope and parse template registrations safely --- TUTORIAL.md | 5 +- .../InsertBeforeStatementQuickFix.java | 28 +++----- .../support/inspections/PhpTextUtil.java | 31 +++++---- .../inspections/XoopsManifestTemplates.java | 25 ++----- ...psMissingRegisteredTemplateInspection.java | 2 +- .../XoopsResultSetGuardInspection.java | 13 +--- .../inspections/ReviewRegressionTest.java | 69 +++++++++++++++++++ 7 files changed, 107 insertions(+), 66 deletions(-) diff --git a/TUTORIAL.md b/TUTORIAL.md index ef2adea..5d279d5 100644 --- a/TUTORIAL.md +++ b/TUTORIAL.md @@ -54,6 +54,7 @@ The counts are a quick health read: **TPL 0** means the module renders nothing o | `DEPRECATED_QUERY_F`, `DEPRECATED_QUOTE_STRING` | 4.1 `XoopsDeprecatedDbApi` | | `RAW_REQUEST` | 4.7 `XoopsSuperglobal`, `$_REQUEST` only; keyed `$_GET` / `$_POST` are left to the editor | | `MISSING_REGISTERED_TEMPLATE` | 4.8 `XoopsMissingRegisteredTemplate` | +| `TEMPLATE_CASE_MISMATCH` | 4.8 `XoopsMissingRegisteredTemplate`: filename/directory spelling differs from disk | | `UNREGISTERED_TEMPLATE` | 4.9 `XoopsUnregisteredTemplate` | | `WRONG_SMARTY_DELIMITER` | 4.10 `XoopsWrongSmartyDelimiter` | | `SCAN_ERROR` | a file or directory the scanner could not read | @@ -138,6 +139,8 @@ An early-exit guard covers later fetches of the same variable, including `while - `&&`, `and` and `xor` in the guard condition disqualify it. `if (!isResultSet($r) && $x) return;` can fall through while `$r` is still `false`. - Comments and strings are masked first, so a commented-out guard never counts. +**Guard quick-fix scope.** The guard is inserted within the existing statement list. Unbraced `if`, `while`, `for` and `foreach` bodies receive a warning without a quick-fix; add braces first so the guard can stay inside the control flow. + ### 4.4 `include_once` for headers — `XoopsIncludeOnceHeader` XOOPS module entry points should load `header.php` / `footer.php` with `include_once` (not bare `include`). @@ -220,7 +223,7 @@ $name = \Xmf\Request::getString('name', '', 'POST'); ### 4.8 Missing registered template — `XoopsMissingRegisteredTemplate` -A template listed in `xoops_version.php` (`file` / `template` key) was not found under the module `templates/` (or `blocks/`) directory. +A template listed in `xoops_version.php` (`file` / `template` key) was not found under the module `templates/` (or `blocks/`) directory. A case-only difference is reported as `TEMPLATE_CASE_MISMATCH`, with the actual disk spelling and no create-file quick-fix. **Why.** On install and update XOOPS reads `$modversion['templates']` and copies each listed file into the `tplfile` table. A missing file is skipped without an error, so the page or block later fails with a Smarty "unable to read resource" message that names a template you registered but never created, or that was renamed on disk without the manifest following. diff --git a/src/main/java/org/xoops/support/inspections/InsertBeforeStatementQuickFix.java b/src/main/java/org/xoops/support/inspections/InsertBeforeStatementQuickFix.java index 0318d11..68e6de6 100644 --- a/src/main/java/org/xoops/support/inspections/InsertBeforeStatementQuickFix.java +++ b/src/main/java/org/xoops/support/inspections/InsertBeforeStatementQuickFix.java @@ -12,6 +12,7 @@ import java.util.regex.Pattern; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; /** * Inserts an isResultSet throw-guard immediately before the enclosing statement of @@ -41,11 +42,10 @@ public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descri if (leaf == null) { return; } - Statement stmt = PsiTreeUtil.getParentOfType(leaf, Statement.class, false); + Statement stmt = insertionStatement(leaf); if (stmt == null) { return; } - stmt = outermostUnbracedAncestor(stmt); Document document = DocumentEditHelper.documentOf(project, leaf); if (document == null) { return; @@ -73,22 +73,16 @@ public void applyFix(@NotNull Project project, @NotNull ProblemDescriptor descri PsiDocumentManager.getInstance(project).commitDocument(document); } - /** - * When the statement is the sole, unbraced body of an if / while / for / foreach, - * inserting before it would put the guard inside that control structure and push - * the fetch out of it. Climb to the control statement instead (an early-exit guard - * before the loop is equivalent) and stop at the first enclosing {@code { }} block. - */ - private static @NotNull Statement outermostUnbracedAncestor(@NotNull Statement stmt) { - Statement current = stmt; - while (true) { - Statement up = PsiTreeUtil.getParentOfType(current, Statement.class, true); - GroupStatement block = PsiTreeUtil.getParentOfType(current, GroupStatement.class, true); - if (up == null || (block != null && PsiTreeUtil.isAncestor(up, block, true))) { - return current; - } - current = up; + /** Only offer insertion where the fetch statement is already in a statement list. */ + static @Nullable Statement insertionStatement(@NotNull PsiElement leaf) { + Statement stmt = PsiTreeUtil.getParentOfType(leaf, Statement.class, false); + if (stmt == null) { + return null; } + Statement up = PsiTreeUtil.getParentOfType(stmt, Statement.class, true); + GroupStatement block = PsiTreeUtil.getParentOfType(stmt, GroupStatement.class, true); + return up == null || up instanceof GroupStatement + || block != null && PsiTreeUtil.isAncestor(up, block, false) ? stmt : null; } static boolean assignsResultBeforeFetch( diff --git a/src/main/java/org/xoops/support/inspections/PhpTextUtil.java b/src/main/java/org/xoops/support/inspections/PhpTextUtil.java index cdcf8c0..67bfec2 100644 --- a/src/main/java/org/xoops/support/inspections/PhpTextUtil.java +++ b/src/main/java/org/xoops/support/inspections/PhpTextUtil.java @@ -153,27 +153,30 @@ static boolean looksLikeVendorOrCache(@NotNull PsiFile file) { while (i < n && chars[i] != '\n') { i++; } - String body = text.substring(lineStart, i).strip(); - boolean closer = false; - if (body.startsWith(ident)) { - if (body.length() == ident.length()) { - closer = true; - } else { - char next = body.charAt(ident.length()); - closer = !Character.isLetterOrDigit(next) && next != '_'; - } + int labelStart = lineStart; + while (labelStart < i && (text.charAt(labelStart) == ' ' || text.charAt(labelStart) == '\t')) { + labelStart++; } + int labelEnd = labelStart + ident.length(); + boolean closer = labelEnd <= i && text.startsWith(ident, labelStart) + && (labelEnd == i || !Character.isLetterOrDigit(text.charAt(labelEnd)) + && text.charAt(labelEnd) != '_'); + int maskEnd = closer ? labelEnd : i; if (maskStrings) { - for (int k = lineStart; k < i; k++) { - chars[k] = ' '; + for (int k = lineStart; k < maskEnd; k++) { + if (chars[k] != '\r') { + chars[k] = ' '; + } } } - if (i < n) { - i++; // newline - } if (closer) { + // Resume PHP at the suffix: punctuation, comments and code can share this line. + i = labelEnd; break; } + if (i < n) { + i++; // newline + } } continue; } diff --git a/src/main/java/org/xoops/support/inspections/XoopsManifestTemplates.java b/src/main/java/org/xoops/support/inspections/XoopsManifestTemplates.java index 64841d4..02754d9 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsManifestTemplates.java +++ b/src/main/java/org/xoops/support/inspections/XoopsManifestTemplates.java @@ -59,7 +59,8 @@ private XoopsManifestTemplates() { searchFrom = stmt.end(); continue; } - int end = statementEnd(commentMasked, stmt.end()); + int end = code.indexOf(';', stmt.end()); + end = end < 0 ? code.length() : end + 1; Section section = "blocks".equalsIgnoreCase(stmt.group(1)) ? Section.BLOCKS : Section.TEMPLATES; Matcher m = FILE_OR_TEMPLATE.matcher(commentMasked); m.region(stmt.end(), end); @@ -93,7 +94,8 @@ private XoopsManifestTemplates() { public static @NotNull String diskPath(@NotNull String name, boolean block) { String n = String.join("/", Arrays.stream(name.replace('\\', '/').split("/")) .filter(part -> !part.isEmpty() && !part.equals(".")).toList()); - if (n.startsWith("templates/") || n.startsWith("blocks/")) { + String lower = n.toLowerCase(Locale.ROOT); + if (lower.startsWith("templates/") || lower.startsWith("blocks/")) { return n; } return block ? "templates/blocks/" + n : "templates/" + n; @@ -107,23 +109,4 @@ private XoopsManifestTemplates() { return diskPath(candidate, false).equals(normalized) ? candidate : normalized; } - /** Offset just past the {@code ;} that ends the statement, skipping {@code ;} inside quotes. */ - private static int statementEnd(@NotNull String text, int from) { - char quote = 0; - for (int i = from; i < text.length(); i++) { - char c = text.charAt(i); - if (quote != 0) { - if (c == '\\') { - i++; - } else if (c == quote) { - quote = 0; - } - } else if (c == '\'' || c == '"') { - quote = c; - } else if (c == ';') { - return i + 1; - } - } - return text.length(); - } } diff --git a/src/main/java/org/xoops/support/inspections/XoopsMissingRegisteredTemplateInspection.java b/src/main/java/org/xoops/support/inspections/XoopsMissingRegisteredTemplateInspection.java index adb00e6..c6aa9ce 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsMissingRegisteredTemplateInspection.java +++ b/src/main/java/org/xoops/support/inspections/XoopsMissingRegisteredTemplateInspection.java @@ -24,7 +24,7 @@ public void visitFile(@NotNull PsiFile file) { if (!XoopsSupportPlugin.isEnabled(file) || !PhpTextUtil.isPrimaryPsiFile(file)) { return; } - if (!"xoops_version.php".equalsIgnoreCase(file.getName())) { + if (!"xoops_version.php".equalsIgnoreCase(file.getName()) || PhpTextUtil.looksLikeVendorOrCache(file)) { return; } VirtualFile vf = file.getVirtualFile(); diff --git a/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java b/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java index 4678aa7..35568e8 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java +++ b/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java @@ -5,7 +5,6 @@ import com.intellij.psi.PsiElement; import com.intellij.psi.PsiElementVisitor; import com.intellij.psi.PsiFile; -import com.intellij.psi.util.PsiTreeUtil; import com.jetbrains.php.lang.psi.elements.Statement; import org.jetbrains.annotations.NotNull; import org.xoops.support.XoopsSupportPlugin; @@ -60,10 +59,6 @@ public final class XoopsResultSetGuardInspection extends LocalInspectionTool { + "\\s*\\)?" ); - private static final Pattern NEG_INSTANCEOF = Pattern.compile( - "(?is)!\\s*\\(?\\s*(\\$[A-Za-z_][\\w]*)\\s*instanceof" - ); - private static final Pattern INSTANCEOF_RESULT = Pattern.compile( "(?is)\\$[A-Za-z_][\\w]*\\s+instanceof\\s+\\\\?mysqli_result" ); @@ -101,7 +96,7 @@ public void visitFile(@NotNull PsiFile file) { } String message = "XOOPS: call isResultSet($result) (and prefer mysqli_result check) before fetch*"; - Statement stmt = PsiTreeUtil.getParentOfType(leaf, Statement.class, false); + Statement stmt = InsertBeforeStatementQuickFix.insertionStatement(leaf); if (stmt == null) { holder.registerProblem(leaf, message); continue; @@ -417,12 +412,6 @@ private static boolean conditionNegatesIsResultSet(@NotNull String cond, @NotNul return true; } } - Matcher mi = NEG_INSTANCEOF.matcher(cond); - while (mi.find()) { - if (resultVar.equals(mi.group(1))) { - return true; - } - } return false; } diff --git a/src/test/java/org/xoops/support/inspections/ReviewRegressionTest.java b/src/test/java/org/xoops/support/inspections/ReviewRegressionTest.java index 48fa8a2..6c83d0c 100644 --- a/src/test/java/org/xoops/support/inspections/ReviewRegressionTest.java +++ b/src/test/java/org/xoops/support/inspections/ReviewRegressionTest.java @@ -59,4 +59,73 @@ public void testTemplateCaseMismatchIsReportedWithoutCreateFix() { new XoopsUnregisteredTemplateInspection().buildVisitor(inverse, false).visitFile(template); assertTrue(inverse.getResults().isEmpty()); } + public void testPrefixedPathsPreserveCase() { + assertEquals("Templates/Admin.tpl", XoopsManifestTemplates.diskPath("Templates/Admin.tpl", false)); + assertEquals("Blocks/Header.tpl", XoopsManifestTemplates.diskPath("Blocks/Header.tpl", true)); + } + public void testHeredocDescriptionDoesNotHideRegistration() { + String source = """ + << 'real.tpl' + ]; + """; + assertEquals(java.util.Set.of("templates/real.tpl"), XoopsManifestTemplates.keys(source)); + } + public void testArbitraryInstanceofDoesNotGuardFetch() { + assertEquals(1, XoopsResultSetGuardInspection.unguardedFetchOffsets( + "fetchArray($result);").size()); + } + public void testVendorManifestIsIgnored() { + for (String prefix : new String[]{"vendor", "cache", "templates_c", "node_modules"}) { + var file = myFixture.addFileToProject(prefix + "/demo/xoops_version.php", + " 'missing.tpl'];"); + var holder = new com.intellij.codeInspection.ProblemsHolder(InspectionManager.getInstance(getProject()), file, false); + new XoopsMissingRegisteredTemplateInspection().buildVisitor(holder, false).visitFile(file); + assertTrue(prefix, holder.getResults().isEmpty()); + } + } + public void testQuickFixDoesNotHoistGuardOutOfConditional() { + int index = 0; + for (String control : new String[]{"if ($enabled)", "while ($enabled)", + "for ($i = 0; $i < 2; $i++)", "foreach ($items as $item)"}) { + String source = "fetchArray($result);"; + var file = myFixture.addFileToProject("modules/demo/include/example" + index++ + ".php", source); + var leaf = file.findElementAt(source.indexOf("$db")); + var descriptor = InspectionManager.getInstance(getProject()).createProblemDescriptor( + leaf, "Unguarded", false, new com.intellij.codeInspection.LocalQuickFix[0], ProblemHighlightType.GENERIC_ERROR_OR_WARNING); + WriteCommandAction.runWriteCommandAction(getProject(), () -> + new InsertBeforeStatementQuickFix("$db", "$result").applyFix(getProject(), descriptor)); + assertEquals(source, file.getText()); + var holder = new com.intellij.codeInspection.ProblemsHolder(InspectionManager.getInstance(getProject()), file, false); + new XoopsResultSetGuardInspection().buildVisitor(holder, false).visitFile(file); + assertEquals(1, holder.getResults().size()); + var fixes = holder.getResults().get(0).getFixes(); + assertTrue(fixes == null || fixes.length == 0); + } + } + public void testGuardFixStillWorksInBracedBody() { + String source = "fetchArray($result); }"; + var file = myFixture.addFileToProject("modules/demo/include/braced.php", source); + var holder = new com.intellij.codeInspection.ProblemsHolder(InspectionManager.getInstance(getProject()), file, false); + new XoopsResultSetGuardInspection().buildVisitor(holder, false).visitFile(file); + assertEquals(1, holder.getResults().size()); + var fix = (com.intellij.codeInspection.LocalQuickFix) holder.getResults().get(0).getFixes()[0]; + WriteCommandAction.runWriteCommandAction(getProject(), () -> fix.applyFix(getProject(), holder.getResults().get(0))); + assertTrue(file.getText().startsWith(" <<<" + opener + + "\nText; text\n DESC, 'file' => 'real.tpl'];\n" + + "$modversion['blocks'][1]['template'] = 'block.tpl';"; + assertEquals(java.util.Set.of("templates/real.tpl", "templates/blocks/block.tpl"), + XoopsManifestTemplates.keys(source)); + } + } } From e156145faebeb51055355ba17e36dd81283ac5f8 Mon Sep 17 00:00:00 2001 From: Michael Beck Date: Tue, 22 Sep 2026 00:51:34 -0400 Subject: [PATCH 20/20] refactor: simplify guard checks and correct quick-fix release note --- CHANGELOG.md | 2 +- .../inspections/XoopsManifestTemplates.java | 10 ++-- .../XoopsResultSetGuardInspection.java | 48 +++++++++---------- 3 files changed, 31 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5176da..d3135cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ _Nothing yet._ - Language-constant index keeps original spelling; resolution is exact-case (name recognition stays case-insensitive). Only a `$smarty.const.` prefix is stripped. - isResultSet reassignment ends at `;`, `,`, an unmatched closer, or `or`/`and`/`xor`; `?:`, `??`, `||`, `&&` keep the fetch inside the assignment. - ROOT_PATH guard is still required for includes that start with HTML (quick-fix declines without a file-leading open tag); stub detection ignores `;` inside quotes. -- `xor` in an early-exit condition is not a dominating isResultSet guard; language-constant cap is enforced before the 5001st name; scanner accepts `templates/…` and `blocks/…` manifest spellings and honours Cancel while walking template trees; the unregistered-template inspection applies the same manifest-spelling rule. A block template at `templates/blocks/foo.tpl` registered as `foo.tpl` (the XOOPS convention) is neither "unregistered" nor "missing"; `$modversion['blocks'][1]['template'] = '…'` assignment syntax is read as a registration alongside `'template' => '…'`; commented-out entries are ignored by the scanner too. A positive `isResultSet(...) xor …` guard is not a guard. The comment mask steps over quoted strings, so `//` or `#` inside a string (a URL in a description) no longer hides the rest of the line from the manifest, guard and template readers. A `module.json`-only module is not scanned for unregistered templates. The missing-registered-template inspection reads the manifest through the comment-only mask (it had masked the string literals it needed and never reported). `define()` must be a real call (`mydefine('_MI_…')` is not indexed). The register-template quick-fix ignores commented-out entries. A positive `isResultSet` guard does not cover a fetch inside a closure. The ROOT_PATH quick-fix is attached only where it can insert. An unreadable or oversized `xoops_version.php` yields one `SCAN_ERROR` instead of every template being reported unregistered. Commented-out `define()` lines are not indexed as language constants. Heredoc/nowdoc bodies are skipped by the comment-only mask, so `//` or `/*` inside them cannot hide later code. A VFS event on the `language` directory itself invalidates the constant cache. A `define()` inside a string literal is not indexed. The isResultSet quick-fix inserts before the enclosing unbraced `if`/`while`/`for` rather than inside it. Bootstrap detection matches exact filenames (`custom-header.php`, `mainfile.php.bak` do not count). Template registrations are read only from `$modversion['templates']` / `$modversion['blocks']` statements (shared `XoopsManifestTemplates` reader). An `elseif` early exit is not a dominating guard. Core version detection ignores `12.5` / `12.7` / `14.0`. The isResultSet quick-fix declines when the statement assigns the result before fetching. Ctrl+B prefers a `define()` in the same module when several modules define the same name. +- `xor` in an early-exit condition is not a dominating isResultSet guard; language-constant cap is enforced before the 5001st name; scanner accepts `templates/…` and `blocks/…` manifest spellings and honours Cancel while walking template trees; the unregistered-template inspection applies the same manifest-spelling rule. A block template at `templates/blocks/foo.tpl` registered as `foo.tpl` (the XOOPS convention) is neither "unregistered" nor "missing"; `$modversion['blocks'][1]['template'] = '…'` assignment syntax is read as a registration alongside `'template' => '…'`; commented-out entries are ignored by the scanner too. A positive `isResultSet(...) xor …` guard is not a guard. The comment mask steps over quoted strings, so `//` or `#` inside a string (a URL in a description) no longer hides the rest of the line from the manifest, guard and template readers. A `module.json`-only module is not scanned for unregistered templates. The missing-registered-template inspection reads the manifest through the comment-only mask (it had masked the string literals it needed and never reported). `define()` must be a real call (`mydefine('_MI_…')` is not indexed). The register-template quick-fix ignores commented-out entries. A positive `isResultSet` guard does not cover a fetch inside a closure. The ROOT_PATH quick-fix is attached only where it can insert. An unreadable or oversized `xoops_version.php` yields one `SCAN_ERROR` instead of every template being reported unregistered. Commented-out `define()` lines are not indexed as language constants. Heredoc/nowdoc bodies are skipped by the comment-only mask, so `//` or `/*` inside them cannot hide later code. A VFS event on the `language` directory itself invalidates the constant cache. A `define()` inside a string literal is not indexed. Unbraced `if`/`while`/`for`/`foreach` bodies are reported without an isResultSet quick-fix; add braces before applying the guard fix. Bootstrap detection matches exact filenames (`custom-header.php`, `mainfile.php.bak` do not count). Template registrations are read only from `$modversion['templates']` / `$modversion['blocks']` statements (shared `XoopsManifestTemplates` reader). An `elseif` early exit is not a dominating guard. Core version detection ignores `12.5` / `12.7` / `14.0`. The isResultSet quick-fix declines when the statement assigns the result before fetching. Ctrl+B prefers a `define()` in the same module when several modules define the same name. ### Added diff --git a/src/main/java/org/xoops/support/inspections/XoopsManifestTemplates.java b/src/main/java/org/xoops/support/inspections/XoopsManifestTemplates.java index 02754d9..2142d74 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsManifestTemplates.java +++ b/src/main/java/org/xoops/support/inspections/XoopsManifestTemplates.java @@ -22,6 +22,8 @@ */ public final class XoopsManifestTemplates { + private static final String TEMPLATES_PREFIX = "templates/"; + public enum Section { TEMPLATES, BLOCKS @@ -95,17 +97,17 @@ private XoopsManifestTemplates() { String n = String.join("/", Arrays.stream(name.replace('\\', '/').split("/")) .filter(part -> !part.isEmpty() && !part.equals(".")).toList()); String lower = n.toLowerCase(Locale.ROOT); - if (lower.startsWith("templates/") || lower.startsWith("blocks/")) { + if (lower.startsWith(TEMPLATES_PREFIX) || lower.startsWith("blocks/")) { return n; } - return block ? "templates/blocks/" + n : "templates/" + n; + return block ? TEMPLATES_PREFIX + "blocks/" + n : TEMPLATES_PREFIX + n; } /** Shortest registration spelling that still resolves to the same module-relative path. */ public static @NotNull String registrationName(@NotNull String relativePath) { String normalized = relativePath.replace('\\', '/'); - String candidate = normalized.startsWith("templates/") - ? normalized.substring("templates/".length()) : normalized; + String candidate = normalized.startsWith(TEMPLATES_PREFIX) + ? normalized.substring(TEMPLATES_PREFIX.length()) : normalized; return diskPath(candidate, false).equals(normalized) ? candidate : normalized; } diff --git a/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java b/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java index 35568e8..2fea082 100644 --- a/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java +++ b/src/main/java/org/xoops/support/inspections/XoopsResultSetGuardInspection.java @@ -507,38 +507,38 @@ private static boolean isSafeEarlyExitCondition(@NotNull String cond, @NotNull S private static boolean hasUnprovenPolarity(@NotNull String condition) { // ponytail: only simple negated atoms are proven; other expression forms need PSI. - String operators = condition; - for (int i = 0; i < operators.length(); i++) { - if (operators.charAt(i) == '>' && i > 0 && operators.charAt(i - 1) == '-') { + for (int i = 0; i < condition.length(); i++) { + if (condition.charAt(i) == '>' && i > 0 && condition.charAt(i - 1) == '-') { continue; } - if ("=<>?:".indexOf(operators.charAt(i)) >= 0) { + if ("=<>?:".indexOf(condition.charAt(i)) >= 0 + || condition.charAt(i) == '!' && hasUnprovenNegation(condition, i)) { return true; } - if (operators.charAt(i) == '!') { - int next = i + 1; - while (next < operators.length() && Character.isWhitespace(operators.charAt(next))) { - next++; - } - if (next < operators.length() && operators.charAt(next) == '!') { - return true; - } - if (next < operators.length() && operators.charAt(next) == '(') { - int close = matchingCloseParen(operators, next); - if (close < 0) { - return true; - } - String group = operators.substring(next + 1, close); - if (!NEG_IS_RESULT_SET.matcher(operators.substring(i, close + 1)).matches() - && !INSTANCEOF_RESULT.matcher(group.strip()).matches()) { - return true; - } - } - } } return false; } + private static boolean hasUnprovenNegation(@NotNull String condition, int bangIndex) { + int next = bangIndex + 1; + while (next < condition.length() && Character.isWhitespace(condition.charAt(next))) { + next++; + } + if (next < condition.length() && condition.charAt(next) == '!') { + return true; + } + if (next >= condition.length() || condition.charAt(next) != '(') { + return false; + } + int close = matchingCloseParen(condition, next); + if (close < 0) { + return true; + } + String group = condition.substring(next + 1, close); + return !NEG_IS_RESULT_SET.matcher(condition.substring(bangIndex, close + 1)).matches() + && !INSTANCEOF_RESULT.matcher(group.strip()).matches(); + } + private static boolean isInsidePositiveIsResultSetGuard( @NotNull String code, int fetchOffset,