diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aad48823078..77171b1267e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -394,6 +394,33 @@ jobs: node-version: 20 - run: packages/php-wasm/cli/tests/smoke-test.sh + test-legacy-wp-version-boot: + if: github.repository == 'WordPress/wordpress-playground' || github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: ./.github/actions/prepare-playground + - name: Install Playwright Browser + run: npx playwright install chromium --with-deps + - name: Start dev server + run: | + npm run dev > /tmp/playground-dev.log 2>&1 & + timeout=120; elapsed=0 + until curl -s -o /dev/null http://127.0.0.1:5400/website-server/ 2>/dev/null; do + sleep 3 + elapsed=$((elapsed + 3)) + if [ $elapsed -ge $timeout ]; then + echo "Dev server failed to start within ${timeout}s" + cat /tmp/playground-dev.log | tail -50 + exit 1 + fi + done + - name: Test legacy WordPress version boot + run: node packages/playground/wordpress/tests/test-legacy-wp-version-boot.mjs + # Redis extension tests - verifies the php-redis extension loads # and provides the expected API, and can connect to a real Redis server. # Redis requires JSPI because asyncify cannot properly handle exceptions diff --git a/packages/playground/cli/src/blueprints-v1/blueprints-v1-handler.ts b/packages/playground/cli/src/blueprints-v1/blueprints-v1-handler.ts index a30ca4e01b0..be204ee02c3 100644 --- a/packages/playground/cli/src/blueprints-v1/blueprints-v1-handler.ts +++ b/packages/playground/cli/src/blueprints-v1/blueprints-v1-handler.ts @@ -117,11 +117,11 @@ export class BlueprintsV1Handler { sqliteIntegrationPluginZip = undefined; } else { this.cliOutput.updateProgress('Preparing SQLite database'); - // Use pre-patched v2.2.22 for legacy PHP (closures replaced + // Use pre-patched v3.0.0-rc.3 for legacy PHP (closures replaced // with named functions, PHP 5.2 polyfills added offline). const phpVersion = this.args.php || RecommendedPHPVersion; const isLegacyPhp = isLegacyPHPVersion(phpVersion); - const sqliteVersion = isLegacyPhp ? 'v2.2.22-php52' : 'trunk'; + const sqliteVersion = isLegacyPhp ? 'v3.0.0-rc.3-php52' : 'trunk'; sqliteIntegrationPluginZip = await fetchSqliteIntegration(sqliteVersion); } diff --git a/packages/playground/cli/src/blueprints-v1/download.ts b/packages/playground/cli/src/blueprints-v1/download.ts index 094f07345bb..ea90f484970 100644 --- a/packages/playground/cli/src/blueprints-v1/download.ts +++ b/packages/playground/cli/src/blueprints-v1/download.ts @@ -7,7 +7,7 @@ import path, { basename } from 'path'; export const CACHE_FOLDER = path.join(os.homedir(), '.wordpress-playground'); export async function fetchSqliteIntegration( - version: 'trunk' | 'v2.1.16' | 'v2.2.22' | 'v2.2.22-php52' = 'trunk' + version: 'trunk' | 'v2.1.16' | 'v3.0.0-rc.3-php52' = 'trunk' ): Promise { // Production builds: the ZIP sits next to the bundled JS. const dir = diff --git a/packages/playground/cli/tests/run-cli.spec.ts b/packages/playground/cli/tests/run-cli.spec.ts index c55e1d6ed9e..228455bf46a 100644 --- a/packages/playground/cli/tests/run-cli.spec.ts +++ b/packages/playground/cli/tests/run-cli.spec.ts @@ -329,6 +329,81 @@ describe.each(blueprintVersions)( expect(response.text).toContain('My WordPress Website'); }); + // Regression test: Playground must not write its own drop-ins + // (db.php, object-cache.php, advanced-cache.php, sunrise.php) + // into a user-mounted wp-content. Studio and other consumers + // mount real wp-content directories into Playground, and any + // Playground-managed file written at the wp-content root would + // silently take over the user's external site. + test('should not drop any new files at the wp-content root when wp-content is mounted', async () => { + const hostWpContent = await mkdtemp( + path.join(tmpdir(), 'playground-test-mount-wpcontent-') + ); + // Minimal wp-content skeleton. `plugins/` and `themes/` + // stay empty so WP's unzip step fills them in; any file + // added at the root of hostWpContent after boot must come + // from Playground itself. + mkdirSync(path.join(hostWpContent, 'plugins')); + mkdirSync(path.join(hostWpContent, 'themes')); + writeFileSync( + path.join(hostWpContent, 'index.php'), + ' !filesBefore.has(f)) + .filter( + (f) => + !lstatSync( + path.join(hostWpContent, f) + ).isDirectory() + ); + expect(unexpectedNewFiles).toEqual([]); + } finally { + rmSync(hostWpContent, { recursive: true, force: true }); + } + }, 120000); + // Regression test: mounting files under /tmp (which is already // NODEFS-mounted to a shared host directory) used to race // across 6 workers and intermittently fail with ErrnoError 20 diff --git a/packages/playground/client/src/blueprints-v1-handler.ts b/packages/playground/client/src/blueprints-v1-handler.ts index 7e5655195d1..34fe86fa6fe 100644 --- a/packages/playground/client/src/blueprints-v1-handler.ts +++ b/packages/playground/client/src/blueprints-v1-handler.ts @@ -89,7 +89,10 @@ export class BlueprintsV1Handler { * Pre-fetch WordPress update checks to speed up the initial wp-admin load. * Skip for old WordPress versions — the functions called by prefetch * (wp_check_php_version, wp_update_plugins, etc.) don't exist or crash - * on legacy WP, and the resulting PHP errors create noise. + * on legacy WP, and the resulting PHP errors create noise. WP 5.0 + * (Gutenberg 1.0) also crashes the runtime with exit code 255 inside + * prefetchUpdateChecks when using the modern SQLite driver, so extend + * the skip range up to (but not including) WP 5.1. * * parseFloat extracts the major version from strings like "6.8", * "4.9.26", etc. Non-numeric values like "nightly" or "trunk" @@ -99,7 +102,7 @@ export class BlueprintsV1Handler { * @see https://github.com/WordPress/wordpress-playground/pull/2295 */ const wpMajor = parseFloat(runtimeConfiguration.wpVersion); - const isLegacyWpVersion = Number.isFinite(wpMajor) && wpMajor < 5; + const isLegacyWpVersion = Number.isFinite(wpMajor) && wpMajor < 5.1; if (runtimeConfiguration.networking && !isLegacyWpVersion) { await playground.prefetchUpdateChecks(); } diff --git a/packages/playground/remote/src/lib/playground-worker-endpoint-blueprints-v1.ts b/packages/playground/remote/src/lib/playground-worker-endpoint-blueprints-v1.ts index 40bda829346..82966198076 100644 --- a/packages/playground/remote/src/lib/playground-worker-endpoint-blueprints-v1.ts +++ b/packages/playground/remote/src/lib/playground-worker-endpoint-blueprints-v1.ts @@ -154,12 +154,12 @@ class PlaygroundWorkerEndpointBlueprintsV1 extends PlaygroundWorkerEndpoint { } // Select the right SQLite version: - // - PHP 5.2: pre-patched v2.2.22 (closures replaced, PHP 5.2 + // - PHP 5.2: pre-patched v3.0.0-rc.3 (closures replaced, PHP 5.2 // polyfills added) // - Everything else: whatever the caller requested const isLegacyPhp = isLegacyPHPVersion(phpVersion); const effectiveSqliteVersion = isLegacyPhp - ? 'v2.2.22-php52' + ? 'v3.0.0-rc.3-php52' : sqliteDriverVersion!; const sqliteDriverModuleDetails = getSqliteDriverModuleDetails( effectiveSqliteVersion diff --git a/packages/playground/website/src/components/site-manager/site-settings-form/older-wordpress-versions.ts b/packages/playground/website/src/components/site-manager/site-settings-form/older-wordpress-versions.ts new file mode 100644 index 00000000000..9527e83eb46 --- /dev/null +++ b/packages/playground/website/src/components/site-manager/site-settings-form/older-wordpress-versions.ts @@ -0,0 +1,116 @@ +import type { AllPHPVersion } from '@php-wasm/universal'; + +/** + * WordPress versions that ship as non-minified downloads from + * wordpress.org (or via the Playground CORS proxy). The web worker + * handles these via its `!isMinifiedVersion` branch in + * playground-worker-endpoint-blueprints-v1.ts. + * + * Ordered newest-first so the UI dropdown shows the most recent + * older versions at the top of the "older versions" group. + */ +export const OlderWordPressVersions = [ + // WP 6.0 – 6.2 work on PHP 7.4+ but run best on PHP 8.x. Still not + // minified today, so they're fetched from wordpress.org like the + // legacy bucket. + '6.2', + '6.1', + '6.0', + // WP 5.x — PHP 5.6.20+ required; PHP 7.4 is the safest choice. + '5.9', + '5.8', + '5.7', + '5.6', + '5.5', + '5.4', + '5.3', + '5.2', + '5.1', + '5.0', + // WP 4.x — PHP 5.2.4+ required; our only 5.x WASM build is 5.2. + '4.9', + '4.8', + '4.7', + '4.6', + '4.5', + '4.4', + '4.3', + '4.2', + '4.1', + '4.0', + // WP 3.x + '3.9', + '3.8', + '3.7', + '3.6', + '3.5', + '3.4', + '3.3', + '3.2', + '3.1', + '3.0', + // WP 2.x (2.4 was never released) + '2.9', + '2.8', + '2.7', + '2.6', + '2.5', + '2.3', + '2.2', + '2.1', + '2.0', + // WP 1.x (1.1, 1.3, 1.4 were never released) + '1.5', + '1.2', + '1.0', +] as const; + +export type OlderWordPressVersion = (typeof OlderWordPressVersions)[number]; + +/** + * Returns the PHP version a given WordPress release must run on + * inside Playground, or `null` if any supported modern PHP version + * will do. + * + * - WP < 5.0 (the legacy bucket): only our PHP 5.2 WASM build works. + * WP 4.x officially requires PHP 5.2.4+, but Playground's 5.6+ + * builds have been retired so 5.2 is the only option available + * here. + * - WP 5.0 – 6.2 (the older-but-not-legacy bucket): PHP 7.4 is the + * safest single choice — old enough for WP 5.0's PHP 5.2.4 era + * code (which runs fine on 7.4) yet new enough that nothing + * depends on PHP 5 quirks. PHP 8.x would work for WP 5.6+ but not + * reliably for WP 5.0 – 5.5, so we force 7.4 across the whole + * bucket. + * - WP 6.3+ (the minified bucket): returns `null`. The UI lets the + * user pick any supported PHP version and we default to the + * recommended one. + */ +export function getForcedPhpVersionForWordPress( + wpVersion: string | undefined +): AllPHPVersion | null { + if (!wpVersion) { + return null; + } + const major = parseFloat(wpVersion); + if (!Number.isFinite(major)) { + return null; + } + if (major < 5) { + return '5.2'; + } + if (major < 6.3) { + return '7.4'; + } + return null; +} + +/** True for WP versions that live in the non-minified "older" bucket. */ +export function isOlderWordPressVersion( + wpVersion: string | undefined +): boolean { + if (!wpVersion) { + return false; + } + return (OlderWordPressVersions as readonly string[]).includes(wpVersion); +} diff --git a/packages/playground/website/src/components/site-manager/site-settings-form/unconnected-site-settings-form.tsx b/packages/playground/website/src/components/site-manager/site-settings-form/unconnected-site-settings-form.tsx index a1eccf6abbe..297a4e2485e 100644 --- a/packages/playground/website/src/components/site-manager/site-settings-form/unconnected-site-settings-form.tsx +++ b/packages/playground/website/src/components/site-manager/site-settings-form/unconnected-site-settings-form.tsx @@ -1,13 +1,19 @@ -import type { SupportedPHPVersion } from '@php-wasm/universal'; +import type { AllPHPVersion } from '@php-wasm/universal'; import { SupportedPHPVersionsList } from '@php-wasm/universal'; import css from './style.module.css'; import { CheckboxControl, SelectControl } from '@wordpress/components'; -import { useEffect, useMemo } from 'react'; -import { Controller, useForm } from 'react-hook-form'; +import { useEffect, useMemo, useState } from 'react'; +import { Controller, useForm, useWatch } from 'react-hook-form'; import classNames from 'classnames'; import { __experimentalVStack as VStack } from '@wordpress/components'; import { useSupportedWordPressVersions } from './use-supported-wordpress-versions'; import { RecommendedPHPVersion } from '@wp-playground/common'; +import { + getForcedPhpVersionForWordPress, + isOlderWordPressVersion, + OlderWordPressVersions, +} from './older-wordpress-versions'; +import { formatWordPressVersionLabel } from './wordpress-release-names'; type ConfigurableFields = Record< keyof SiteFormData & ('wpVersion' | 'language' | 'multisite'), @@ -24,7 +30,7 @@ export interface SiteSettingsFormProps { } export interface SiteFormData { - phpVersion: SupportedPHPVersion; + phpVersion: AllPHPVersion; wpVersion: string; language: string; withNetworking: boolean; @@ -45,7 +51,7 @@ export function UnconnectedSiteSettingsForm({ }: SiteSettingsFormProps) { const mergedDefaults = useMemo( () => ({ - phpVersion: RecommendedPHPVersion as SupportedPHPVersion, + phpVersion: RecommendedPHPVersion as AllPHPVersion, wpVersion: 'latest', language: '', withNetworking: true, @@ -68,6 +74,16 @@ export function UnconnectedSiteSettingsForm({ const { supportedWPVersions, latestWPVersion } = useSupportedWordPressVersions(); + // If the caller restored a stored site running an older WP + // version, expand the dropdown automatically so the current + // value is visible in the list. + const [includeOlderVersions, setIncludeOlderVersions] = useState(() => + isOlderWordPressVersion(mergedDefaults.wpVersion) + ); + + const currentWpVersion = useWatch({ control, name: 'wpVersion' }); + const forcedPhpVersion = getForcedPhpVersionForWordPress(currentWpVersion); + useEffect(() => { if ( latestWPVersion && @@ -77,6 +93,83 @@ export function UnconnectedSiteSettingsForm({ } }, [latestWPVersion, setValue, getValues]); + // Lock phpVersion to whatever is compatible with the selected + // WordPress release. The callback fires on every wpVersion + // change, so picking a different modern version (say 6.5 → 4.9) + // correctly downgrades PHP, and picking 4.9 → 6.5 releases the + // lock back to the recommended default. + useEffect(() => { + const current = getValues('phpVersion'); + if (forcedPhpVersion) { + if (current !== forcedPhpVersion) { + setValue('phpVersion', forcedPhpVersion); + } + return; + } + // Unlocking: if the current value isn't one of the modern + // supported versions (e.g. it was just 5.2 or 7.4 for a + // locked older WP), reset to the recommended default so the + // dropdown doesn't render a value that isn't in its options. + if ( + !(SupportedPHPVersionsList as readonly string[]).includes(current) + ) { + setValue('phpVersion', RecommendedPHPVersion as AllPHPVersion); + } + }, [forcedPhpVersion, setValue, getValues]); + + const wpVersionOptions = useMemo(() => { + const modernOptions = Object.keys(supportedWPVersions || {}).map( + (version) => ({ + label: formatWordPressVersionLabel( + `${supportedWPVersions[version]}` + ), + value: version, + }) + ); + if (!includeOlderVersions) { + return [ + // Without an empty option, React sometimes says the + // current selected version is "trunk" when `wp` is + // actually "6.4". + { label: '-- Select a version --', value: '' }, + ...modernOptions, + ]; + } + return [ + { label: '-- Select a version --', value: '' }, + { + label: '── Current versions ──', + value: '__modern_sep', + disabled: true, + }, + ...modernOptions, + { + label: '── Older versions ──', + value: '__older_sep', + disabled: true, + }, + ...OlderWordPressVersions.map((version) => ({ + label: formatWordPressVersionLabel(version), + value: version, + })), + ]; + }, [supportedWPVersions, includeOlderVersions]); + + const phpVersionOptions = useMemo(() => { + if (forcedPhpVersion) { + return [ + { + label: `PHP ${forcedPhpVersion}`, + value: forcedPhpVersion, + }, + ]; + } + return SupportedPHPVersionsList.map((version) => ({ + label: `PHP ${version}`, + value: version, + })); + }, [forcedPhpVersion]); + return (
({ - label: `${supportedWPVersions[version]}`, - value: version, - })), - ] - } + options={wpVersionOptions} onChange={(value, extra) => { onChange(extra?.event); }} @@ -131,14 +206,16 @@ export function UnconnectedSiteSettingsForm({ /> {enabledFields.wpVersion && ( - - Need an older version? - + )} )} @@ -159,16 +236,16 @@ export function UnconnectedSiteSettingsForm({ __nextHasNoMarginBottom={true} label="PHP Version" labelPosition="side" - help={errors.phpVersion?.message} + disabled={!!forcedPhpVersion} + help={ + forcedPhpVersion + ? `Locked to PHP ${forcedPhpVersion} for this WordPress version.` + : errors.phpVersion?.message + } className={classNames(css.addSiteInput, { [css.invalidInput]: !!errors.phpVersion, })} - options={SupportedPHPVersionsList.map( - (version) => ({ - label: `PHP ${version}`, - value: version, - }) - )} + options={phpVersionOptions} onChange={(value, extra) => { onChange(extra?.event); }} diff --git a/packages/playground/website/src/components/site-manager/site-settings-form/wordpress-release-names.ts b/packages/playground/website/src/components/site-manager/site-settings-form/wordpress-release-names.ts new file mode 100644 index 00000000000..e710926d3b5 --- /dev/null +++ b/packages/playground/website/src/components/site-manager/site-settings-form/wordpress-release-names.ts @@ -0,0 +1,70 @@ +/** + * Release code names for WordPress versions, taken from the shorthand + * each release's announcement URL uses on wordpress.org/news (e.g. + * `/news/2025/12/gene/` → "Gene" for 6.9). The full jazz-musician names + * are listed at https://wordpress.org/about/history/. + */ +export const WordPressReleaseNames: Record = { + '1.0': 'Miles', + '1.2': 'Mingus', + '1.5': 'Strayhorn', + '2.0': 'Duke', + '2.1': 'Ella', + '2.2': 'Getz', + '2.3': 'Dexter', + '2.5': 'Brecker', + '2.6': 'Tyner', + '2.7': 'Coltrane', + '2.8': 'Baker', + '2.9': 'Carmen', + '3.0': 'Thelonious', + '3.1': 'Reinhardt', + '3.2': 'Gershwin', + '3.3': 'Sonny', + '3.4': 'Green', + '3.5': 'Elvin', + '3.6': 'Oscar', + '3.7': 'Basie', + '3.8': 'Parker', + '3.9': 'Smith', + '4.0': 'Benny', + '4.1': 'Dinah', + '4.2': 'Powell', + '4.3': 'Billie', + '4.4': 'Clifford', + '4.5': 'Coleman', + '4.6': 'Pepper', + '4.7': 'Vaughan', + '4.8': 'Evans', + '4.9': 'Tipton', + '5.0': 'Bebo', + '5.1': 'Betty', + '5.2': 'Jaco', + '5.3': 'Kirk', + '5.4': 'Adderley', + '5.5': 'Eckstine', + '5.6': 'Simone', + '5.7': 'Esperanza', + '5.8': 'Tatum', + '5.9': 'Joséphine', + '6.0': 'Arturo', + '6.1': 'Misha', + '6.2': 'Dolphy', + '6.3': 'Lionel', + '6.4': 'Shirley', + '6.5': 'Regina', + '6.6': 'Dorsey', + '6.7': 'Rollins', + '6.8': 'Cecil', + '6.9': 'Gene', +}; + +/** + * Decorates a WordPress version label with its release code name when + * one is known, e.g. "6.9" → "6.9 (Gene)". Labels that don't map to a + * released version (trunk, beta, unreleased majors) are returned as-is. + */ +export function formatWordPressVersionLabel(label: string): string { + const name = WordPressReleaseNames[label]; + return name ? `${label} (${name})` : label; +} diff --git a/packages/playground/website/src/lib/state/opfs/opfs-site-storage.ts b/packages/playground/website/src/lib/state/opfs/opfs-site-storage.ts index 201ab11cd58..5641ca8dfde 100644 --- a/packages/playground/website/src/lib/state/opfs/opfs-site-storage.ts +++ b/packages/playground/website/src/lib/state/opfs/opfs-site-storage.ts @@ -15,7 +15,7 @@ import { type PHPConstants, getBlueprintDeclaration, } from '@wp-playground/blueprints'; -import type { SupportedPHPVersion } from '@php-wasm/universal'; +import type { AllPHPVersion } from '@php-wasm/universal'; import { RecommendedPHPVersion } from '@wp-playground/common'; import { loadPersistedBlueprintBundle } from './opfs-blueprint-bundle-storage'; @@ -210,7 +210,7 @@ function storedFormatToMetadata(data: string) { * The preferred PHP version to use. * If not specified, the latest supported version will be used */ - php: SupportedPHPVersion | 'latest'; + php: AllPHPVersion | 'latest'; /** * The preferred WordPress version to use. * If not specified, the latest supported version will be used @@ -234,7 +234,7 @@ function storedFormatToMetadata(data: string) { metadata.runtimeConfiguration = { phpVersion: - (legacyConfig.preferredVersions?.php as SupportedPHPVersion) ?? + (legacyConfig.preferredVersions?.php as AllPHPVersion) ?? RecommendedPHPVersion, wpVersion: legacyConfig.preferredVersions?.wp ?? 'latest', intl: legacyConfig.features?.intl ?? false, diff --git a/packages/playground/website/src/lib/state/redux/site-management-api-middleware.ts b/packages/playground/website/src/lib/state/redux/site-management-api-middleware.ts index 4281c79b937..09b97570081 100644 --- a/packages/playground/website/src/lib/state/redux/site-management-api-middleware.ts +++ b/packages/playground/website/src/lib/state/redux/site-management-api-middleware.ts @@ -18,10 +18,10 @@ import { randomSiteName } from './random-site-name'; import { persistTemporarySite } from './persist-temporary-site'; import { selectClientBySiteSlug } from './slice-clients'; import type { PlaygroundClient } from '@wp-playground/remote'; -import type { SupportedPHPVersion } from '@php-wasm/universal'; +import type { AllPHPVersion } from '@php-wasm/universal'; export interface SiteSettings { - phpVersion?: SupportedPHPVersion; + phpVersion?: AllPHPVersion; wpVersion?: string; networking?: boolean; language?: string; @@ -92,7 +92,7 @@ export interface PlaygroundSitesAPI { * @param version The PHP version to use (e.g. `"8.4"`). * @throws When no site is selected or the site is temporary. */ - setPhpVersion(version: SupportedPHPVersion): Promise; + setPhpVersion(version: AllPHPVersion): Promise; /** * Enables or disables network access for the active site @@ -238,7 +238,7 @@ export function createSitesAPI( return { slug: site.slug, storage }; }, - async setPhpVersion(version: SupportedPHPVersion) { + async setPhpVersion(version: AllPHPVersion) { const site = selectActiveSite(getState()); if (!site) { throw new Error('No active site selected'); diff --git a/packages/playground/wordpress-builds/src/sqlite-database-integration/get-sqlite-driver-module-details.ts b/packages/playground/wordpress-builds/src/sqlite-database-integration/get-sqlite-driver-module-details.ts index 56e5c8ae6b4..35a9671471a 100644 --- a/packages/playground/wordpress-builds/src/sqlite-database-integration/get-sqlite-driver-module-details.ts +++ b/packages/playground/wordpress-builds/src/sqlite-database-integration/get-sqlite-driver-module-details.ts @@ -2,6 +2,8 @@ import url_trunk from './sqlite-database-integration-trunk.zip?url'; // @ts-ignore import url_v2_1_16 from './sqlite-database-integration-v2.1.16.zip?url'; +// @ts-ignore +import url_v3_0_0_rc_3_php52 from './sqlite-database-integration-v3.0.0-rc.3-php52.zip?url'; /** * This file was auto generated by: @@ -19,7 +21,6 @@ export function getSqliteDriverModuleDetails( url: string; } { switch (version) { - case 'trunk': /** @ts-ignore */ return { @@ -32,6 +33,12 @@ export function getSqliteDriverModuleDetails( size: 84250, url: url_v2_1_16, }; + case 'v3.0.0-rc.3-php52': + /** @ts-ignore */ + return { + size: 210820, + url: url_v3_0_0_rc_3_php52, + }; } throw new Error( 'Unsupported SQLite integration plugin version: ' + version diff --git a/packages/playground/wordpress-builds/src/sqlite-database-integration/sqlite-database-integration-v3.0.0-rc.3-php52.zip b/packages/playground/wordpress-builds/src/sqlite-database-integration/sqlite-database-integration-v3.0.0-rc.3-php52.zip new file mode 100644 index 00000000000..2a6eaa3c3e0 Binary files /dev/null and b/packages/playground/wordpress-builds/src/sqlite-database-integration/sqlite-database-integration-v3.0.0-rc.3-php52.zip differ diff --git a/packages/playground/wordpress-builds/src/sqlite-database-integration/sqlite-database-integration-v3.0.0-rc.3.zip b/packages/playground/wordpress-builds/src/sqlite-database-integration/sqlite-database-integration-v3.0.0-rc.3.zip new file mode 100644 index 00000000000..c8707bbf17c Binary files /dev/null and b/packages/playground/wordpress-builds/src/sqlite-database-integration/sqlite-database-integration-v3.0.0-rc.3.zip differ diff --git a/packages/playground/wordpress/project.json b/packages/playground/wordpress/project.json index d6d4008f66d..dbf4ad91802 100644 --- a/packages/playground/wordpress/project.json +++ b/packages/playground/wordpress/project.json @@ -71,6 +71,14 @@ "reportsDirectory": "../../../coverage/packages/playground/wordpress" } }, + "test-legacy-wp-version-boot": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "node packages/playground/wordpress/tests/test-legacy-wp-version-boot.mjs" + ] + } + }, "lint": { "executor": "@nx/eslint:lint", "outputs": ["{options.outputFile}"], diff --git a/packages/playground/wordpress/src/boot.ts b/packages/playground/wordpress/src/boot.ts index c266361807e..d1921bb6367 100644 --- a/packages/playground/wordpress/src/boot.ts +++ b/packages/playground/wordpress/src/boot.ts @@ -9,6 +9,7 @@ import type { Remote, } from '@php-wasm/universal'; import { + isLegacyPHPVersion, PHP, PHPRequestHandler, sandboxedSpawnHandlerFactory, @@ -26,6 +27,12 @@ import { import { basename, dirname, joinPaths } from '@php-wasm/util'; import { logger } from '@php-wasm/logger'; import { ensureWpConfig } from './wp-config'; +import { assertDatabasePrerequisites } from './database-prerequisites'; +import { + applyLegacyPhpIniOverrides, + bootLegacyWordPress, +} from './legacy-wp/legacy-boot'; +import { backportWpPreV62MysqlCheck } from './legacy-wp/legacy-fixes'; export type PhpIniOptions = Record; export type Hook = (php: PHP) => void | Promise; @@ -216,6 +223,10 @@ export async function bootWordPress( requestHandler: PHPRequestHandler, options: BootWordPressOptions ) { + if (isLegacyPHPVersion(options.phpVersion)) { + return bootLegacyWordPress(requestHandler, options); + } + const php = await requestHandler.getPrimaryPhp(); if (options.hooks?.beforeWordPressFiles) { await options.hooks.beforeWordPressFiles(php); @@ -257,8 +268,10 @@ export async function bootWordPress( usesSqlite = true; await preloadSqliteIntegration( php, - await options.sqliteIntegrationPluginZip + await options.sqliteIntegrationPluginZip, + { phpVersion: options.phpVersion } ); + await backportWpPreV62MysqlCheck(php, requestHandler.documentRoot); } const installationMode = @@ -320,61 +333,6 @@ export async function bootWordPress( return requestHandler; } -/** - * Checks if database prerequisites are in place before attempting WordPress installation. - * This performs lightweight checks that don't require WordPress to be installed. - */ -async function assertDatabasePrerequisites( - requestHandler: PHPRequestHandler, - { - usesSqlite, - hasCustomDatabasePath, - }: { - usesSqlite: boolean; - hasCustomDatabasePath: boolean; - } -) { - const php = await requestHandler.getPrimaryPhp(); - - // If SQLite integration is preloaded via core, we're good - if (php.isFile('/internal/shared/preload/0-sqlite.php')) { - return; - } - - // Check if a SQLite integration plugin directory exists (even if not provided via zip) - // This handles cases where the directory is mounted via hooks - const sqlitePluginPath = joinPaths( - requestHandler.documentRoot, - 'wp-content/mu-plugins/sqlite-database-integration' - ); - - if (php.isDir(sqlitePluginPath)) { - // The directory exists, we'll validate it after WordPress is installed - return; - } - - // Check if we provided a SQLite integration zip - if (usesSqlite) { - // We provided a zip, so SQLite will be set up during boot - return; - } - - // If we have a custom database path (dataSqlPath option was provided), - // assume it's configured - the actual connection will be validated after installation - if (hasCustomDatabasePath) { - return; - } - - // Check if wp-config.php has real MySQL credentials - if (hasValidMySQLCredentials(php)) { - return; - } - - // No SQLite integration and no MySQL credentials found - // Throw early to avoid attempting installation with no database - throw new Error('Error connecting to the MySQL database.'); -} - async function assertValidDatabaseConnection( requestHandler: PHPRequestHandler ) { @@ -427,6 +385,11 @@ export async function bootRequestHandler(options: BootRequestHandlerOptions) { setPhpIniEntries(php, options.phpIniEntries); } + applyLegacyPhpIniOverrides(php, { + phpVersion: options.phpVersion, + phpIniEntries: options.phpIniEntries, + }); + // Use the new AST-based SQLite driver. // TODO: Remove this once the new driver is the default; when this is closed: // https://github.com/WordPress/sqlite-database-integration/issues/195 @@ -464,7 +427,9 @@ export async function bootRequestHandler(options: BootRequestHandlerOptions) { !php.isFile('/internal/.boot-files-written') ) { // TODO: There is a race here when multiple workers are calling bootRequestHandler(). Fix it. - await setupPlatformLevelMuPlugins(php); + await setupPlatformLevelMuPlugins(php, { + phpVersion: options.phpVersion, + }); await writeFiles(php, '/', options.createFiles || {}); await preloadPhpInfoRoute( php, @@ -653,24 +618,6 @@ export function getFileNotFoundActionForWordPress( }; } -function hasValidMySQLCredentials(php: PHP) { - const wpConfigPath = joinPaths(php.documentRoot, 'wp-config.php'); - if (!php.isFile(wpConfigPath)) return false; - - const wpConfig = php.readFileAsText(wpConfigPath); - - const dbName = wpConfig.match( - /define\s*\(\s*['"]DB_NAME['"]\s*,\s*['"]([^'"]*)['"]/ - ); - const dbUser = wpConfig.match( - /define\s*\(\s*['"]DB_USER['"]\s*,\s*['"]([^'"]*)['"]/ - ); - - if (!dbName || !dbUser) return false; - - return dbName[1] !== 'database_name_here' && dbUser[1] !== 'username_here'; -} - async function isDatabaseConnectionValid(php: PHP) { const result = await php.run({ code: ` 'sqlite', - 'path' => FQDB, - 'driver_path' => defined('WP_MYSQL_ON_SQLITE_LOADER_PATH') - ? WP_MYSQL_ON_SQLITE_LOADER_PATH - : dirname(SQLITE_MAIN_FILE) . '/wp-pdo-mysql-on-sqlite.php', - ); - } else { - $db_info = array( - 'type' => 'mysql', - // TODO: Save MySQL connection config. - ); - } - $wp_env = array('db' => $db_info); - $wp_env_php = sprintf('` - ); - - /** - * WordPress 6.7+ only generates the sitemap.xml → wp-sitemap.xml rewrite - * rule when installed at the domain root. Since Playground may use non-root - * installations, the rule isn't generated. This mu-plugin handles the - * redirect manually by using the site URL to determine the correct base path. - * - * @see https://github.com/WordPress/wordpress-playground/issues/2051 - */ - await php.writeFile( - '/internal/shared/mu-plugins/sitemap-redirect.php', - `__get(...)` expecting WP's + // wpdb::__get() to return the parent's property. That works on WP + // 6.2+ (wpdb declares the same property upstream) but blows up on + // older WordPress, where wpdb's __get() runs `return $this->$name;` + // from the parent class context and PHP refuses to read a child + // class's *private* member — producing a silent fatal Error that + // kills install.php and every subsequent request. Widening the + // declaration to `protected` lets both class contexts reach it and + // leaves behaviour identical on every supported WordPress version. + const sqliteDbClassPath = joinPaths( + SQLITE_PLUGIN_FOLDER, + 'wp-includes/sqlite/class-wp-sqlite-db.php' + ); + if (await php.fileExists(sqliteDbClassPath)) { + const classSource = await php.readFileAsText(sqliteDbClassPath); + const patched = classSource.replace( + 'private $allow_unsafe_unquoted_parameters', + 'protected $allow_unsafe_unquoted_parameters' + ); + if (patched !== classSource) { + await php.writeFile(sqliteDbClassPath, patched); + } + } + // Prevents the SQLite integration from trying to call activate_plugin() await php.defineConstant('SQLITE_MAIN_FILE', '1'); const dbCopy = await php.readFileAsText( @@ -498,83 +430,7 @@ export async function preloadSqliteIntegration( await php.writeFile(SQLITE_MUPLUGIN_PATH, stopIfDbPhpExists + dbPhp); await php.writeFile( `/internal/shared/preload/0-sqlite.php`, - stopIfDbPhpExists + - `load_sqlite_integration(); - if($GLOBALS['wpdb'] === $this) { - throw new Exception('Infinite loop detected in $wpdb – SQLite integration plugin could not be loaded'); - } - return call_user_func_array( - array($GLOBALS['wpdb'], $name), - $arguments - ); - } - public function __get($name) { - $this->load_sqlite_integration(); - if($GLOBALS['wpdb'] === $this) { - throw new Exception('Infinite loop detected in $wpdb – SQLite integration plugin could not be loaded'); - } - return $GLOBALS['wpdb']->$name; - } - public function __set($name, $value) { - $this->load_sqlite_integration(); - if($GLOBALS['wpdb'] === $this) { - throw new Exception('Infinite loop detected in $wpdb – SQLite integration plugin could not be loaded'); - } - $GLOBALS['wpdb']->$name = $value; - } - protected function load_sqlite_integration() { - require_once ${phpVar(SQLITE_MUPLUGIN_PATH)}; - } -} -/** - * The Query Monitor plugin short-circuits in the CLI SAPI. However, in Playground, - * the SAPI is always "cli" at the moment. Let's set a constant to disable the CLI - * detection. - * - * @see https://github.com/WordPress/sqlite-database-integration/pull/212 - * @see https://github.com/WordPress/sqlite-database-integration/pull/215 - */ -define('QM_TESTS', true); -$wpdb = $GLOBALS['wpdb'] = new Playground_SQLite_Integration_Loader(); - -/** - * WordPress is capable of using a preloaded global $wpdb. However, if - * it cannot find the drop-in db.php plugin it still checks whether - * the mysqli_connect() function exists even though it's not used. - * - * What WordPress demands, Playground shall provide. - */ -if(!function_exists('mysqli_connect')) { - function mysqli_connect() {} -} - - ` + buildModernSqlitePreload(stopIfDbPhpExists, SQLITE_MUPLUGIN_PATH) ); /** * Ensure the SQLite integration is loaded and clearly communicate @@ -593,6 +449,28 @@ if(!function_exists('mysqli_connect')) { ); } +/** + * Builds the 0-sqlite.php preload content for modern PHP (7+). + * Matches trunk behavior: require_once, simple db.php guard, + * minimal mysqli_connect stub. + */ +function buildModernSqlitePreload( + stopIfDbPhpExists: string, + muPluginPath: string +): string { + return ( + stopIfDbPhpExists + + `; + } +): void { + if (!isLegacyPHPVersion(options.phpVersion)) return; + const callerDisabled = (options.phpIniEntries?.['disable_functions'] ?? '') + .split(',') + .map((s) => s.trim()) + .filter((s) => s); + const mergedDisabled = Array.from( + new Set([...callerDisabled, ...LEGACY_PHP_DISABLED_NETWORK_FUNCTIONS]) + ).join(','); + const iniOverrides: Record = { + disable_functions: mergedDisabled, + allow_url_fopen: '0', + }; + // PHP 5.2 warns on every date_*() call when date.timezone is + // unset; WP hits those during boot. Default to UTC unless the + // caller set it explicitly. + if (!options.phpIniEntries?.['date.timezone']) { + iniOverrides['date.timezone'] = 'UTC'; + } + setPhpIniEntries(php, iniOverrides); +} + +/** + * Boots a legacy WordPress instance (PHP 5.2 + WP 1.0–4.9 on SQLite). + * + * Mirrors {@link bootWordPress}'s step ordering but runs the legacy + * variant of each step: + * + * * wp-config-sample fallback instead of ensureWpConfig() + * * patchWordPressSourceFiles() for WP source-level fixes + * * full-content db.php drop-in (not a placeholder) + * * defensive install.php dispatch with per-WP-version fallbacks + * * PDO-based post-install schema completion + * * no assertValidDatabaseConnection — loading wp-load.php for + * the check can trigger WASM traps that corrupt the runtime + */ +export async function bootLegacyWordPress( + requestHandler: PHPRequestHandler, + options: BootWordPressOptions +): Promise { + const php = await requestHandler.getPrimaryPhp(); + if (options.hooks?.beforeWordPressFiles) { + await options.hooks.beforeWordPressFiles(php); + } + + if (options.wordPressZip) { + await unzipWordPress(php, await options.wordPressZip); + } + + if (options.constants) { + for (const key in options.constants) { + php.defineConstant(key, options.constants[key]); + } + } + + php.defineConstant('WP_HOME', options.siteUrl); + php.defineConstant('WP_SITEURL', options.siteUrl); + + await copyWpConfigFromSample(php, requestHandler.documentRoot); + await patchWordPressSourceFiles(php, requestHandler.documentRoot); + + if (options.hooks?.beforeDatabaseSetup) { + await options.hooks.beforeDatabaseSetup(php); + } + + let usesSqlite = false; + if (options.sqliteIntegrationPluginZip) { + usesSqlite = true; + await preloadSqliteIntegration( + php, + await options.sqliteIntegrationPluginZip, + { phpVersion: options.phpVersion } + ); + await writeLegacyDbPhp(php, requestHandler.documentRoot); + } + + const installationMode = + options['wordpressInstallMode'] ?? 'download-and-install'; + const hasCustomDatabasePath = !!options.dataSqlPath; + + if ( + installationMode === 'download-and-install' || + installationMode === 'install-from-existing-files' || + // Legacy PHP: isWordPressInstalled() can trigger a WASM trap + // (not a PHP exception) on old WordPress (< 3.0) and corrupt + // the runtime beyond recovery. Always run the installer; it + // is idempotent and its post-install fixups short-circuit + // cheaply when the schema already exists. + installationMode === 'install-from-existing-files-if-needed' + ) { + await assertDatabasePrerequisites(requestHandler, { + usesSqlite, + hasCustomDatabasePath, + }); + await installLegacyWordPress(php, requestHandler); + } + + return requestHandler; +} + +/** + * Skips ensureWpConfig() because php.run() with the large transformer + * code hangs on the PHP 5.2 WASM binary. The pre-built legacy + * WordPress already ships a valid wp-config-sample.php, so a plain + * file copy is sufficient. + */ +async function copyWpConfigFromSample(php: PHP, documentRoot: string) { + const wpConfigPath = joinPaths(documentRoot, 'wp-config.php'); + const samplePath = joinPaths(documentRoot, 'wp-config-sample.php'); + if (!php.fileExists(wpConfigPath) && php.fileExists(samplePath)) { + await php.writeFile( + wpConfigPath, + await php.readFileAsBuffer(samplePath) + ); + } +} + +/** + * Writes the full-content wp-content/db.php drop-in for legacy + * WordPress. WP < 3.0 loads only db.php and skips wp-db.php, so the + * mysql_* stubs from generateDbPhpContent() must be present for the + * SQLite driver to function. + */ +async function writeLegacyDbPhp(php: PHP, documentRoot: string): Promise { + const wpContentDir = joinPaths(documentRoot, 'wp-content'); + const dbPhpPath = joinPaths(wpContentDir, 'db.php'); + if (php.isDir(wpContentDir) && !php.fileExists(dbPhpPath)) { + await php.writeFile(dbPhpPath, generateDbPhpContent()); + } +} + +/** + * Runs the legacy WordPress install flow. + * + * Wraps the installer with defensive error handling: old WP + * installers (WP 1.x especially) routinely fail halfway through, and + * we rely on {@link runPostInstallLegacyFixups} to finish building + * the schema via direct PDO writes. This helper therefore *always* + * proceeds to the fixups regardless of whether the installer threw, + * and only logs a warning on error. + */ +async function installLegacyWordPress( + php: PHP, + requestHandler: PHPRequestHandler +): Promise { + try { + await runLegacyInstaller(php); + } catch (error) { + logger.warn('Legacy PHP WordPress installation error:', error); + } + await runPostInstallLegacyFixups(php, requestHandler.absoluteUrl); +} + +/** + * Runs the legacy install.php?step=2 POST (with dispatch to a DB- + * only fallback for WP versions where the installer crashes). Throws + * on unambiguous install failure; {@link installLegacyWordPress} + * catches the throw and proceeds to post-install fixups regardless. + */ +async function runLegacyInstaller(php: PHP): Promise { + // WP 1.0–3.0 installers trigger WASM traps (mail(), + // mysql_get_server_info(), etc.) on the PHP 5.2 binary, so skip + // the install.php HTTP request entirely. + // WP 1.0–1.2: post-install PDO fallback builds the full schema. + // WP 1.5–3.0: needs dbDelta() for the schema; the rest is left + // to the PDO fallback. + const wpVersion = readOnDiskWpVersion(php, php.documentRoot); + if (wpVersion !== null) { + const parsed = parseFloat(wpVersion); + if (parsed < 2.1) { + return; + } + if (parsed <= 3.0) { + await runDbDeltaOnly(php); + return; + } + } + + // withPHPIniValues replaces values wholesale, so re-list every + // function from LEGACY_PHP_DISABLED_NETWORK_FUNCTIONS (mail() is + // already in there — the installer otherwise calls it and crashes). + // error_reporting is suppressed at the ini level because old WP + // class declarations trigger E_STRICT at compile time, which PHP + // reports against the ini value rather than the runtime + // error_reporting() call. + const iniOverrides: Record = { + disable_functions: LEGACY_PHP_DISABLED_NETWORK_FUNCTIONS.join(','), + allow_url_fopen: '0', + error_reporting: String(LEGACY_WP_ERROR_REPORTING_VALUE), + }; + + const response = await withPHPIniValues( + php, + iniOverrides, + async () => + await php.request({ + url: '/wp-admin/install.php?step=2', + method: 'POST', + body: { + language: 'en', + prefix: 'wp_', + weblog_title: 'My WordPress Website', + user_name: 'admin', + admin_password: 'password', + admin_password2: 'password', + Submit: 'Install WordPress', + pw_weak: '1', + admin_email: 'admin@localhost.com', + }, + }) + ); + + // isWordPressInstalled() can WASM-trap on old WP (< 3.0) and + // corrupt the runtime, so detect success from the installer + // response text instead. + const installSucceeded = + response.text?.includes('Success') || + response.text?.includes('successful') || + response.text?.includes('Finished') || + response.text?.includes('Already Installed') || + response.text?.includes('already have WordPress installed') || + false; + if (!installSucceeded) { + throw new Error( + `Failed to install WordPress – installer responded with "${response.text?.substring( + 0, + 100 + )}"` + ); + } + + await setLegacyPermalinkStructureViaPdo(php); +} + +/** + * Sets permalink_structure via PDO on legacy WP. update_option() + * can't be used because on WP < 4.8.3, wpdb::prepare() passes the + * value through vsprintf() without escaping '%' characters first + * (the placeholder_escape mechanism was added in 4.8.3). The '%y', + * '%m', '%d', '%p' sequences in the permalink pattern are + * interpreted as sprintf format specifiers, mangling the stored + * value. PDO bypasses wpdb entirely. + */ +async function setLegacyPermalinkStructureViaPdo(php: PHP): Promise { + try { + const result = await php.run({ + code: `setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $nice_permalinks = '/%year%/%monthnum%/%day%/%postname%/'; + $stmt = $pdo->prepare( + "UPDATE wp_options SET option_value = :val WHERE option_name = 'permalink_structure'" + ); + $stmt->execute(array(':val' => $nice_permalinks)); + if ($stmt->rowCount() === 0) { + $stmt = $pdo->prepare( + "INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('permalink_structure', :val, 'yes')" + ); + $stmt->execute(array(':val' => $nice_permalinks)); + } + $check = $pdo->query( + "SELECT option_value FROM wp_options WHERE option_name = 'permalink_structure'" + )->fetchColumn(); + echo $check === $nice_permalinks ? '1' : '0'; + `, + env: { DOCUMENT_ROOT: php.documentRoot }, + }); + if (result.text !== '1') { + logger.warn( + 'Failed to default to pretty permalinks after WP install.' + ); + } + } catch { + logger.warn( + 'Failed to set pretty permalinks after WP install (non-fatal).' + ); + } +} + +/** + * Runs dbDelta() and populate_options/populate_roles without the + * full wp_install(). Used for WP 2.1–3.0 where install.php crashes + * but we still need the table schemas. + */ +async function runDbDeltaOnly(php: PHP): Promise { + try { + await php.run({ + code: ` { + const wpVersionString = readOnDiskWpVersion(php, documentRoot); + if (wpVersionString === null) return; + const wpVersion = parseFloat(wpVersionString); + if (!Number.isFinite(wpVersion) || wpVersion < 5.0 || wpVersion >= 6.2) { + return; + } + + const loadPhp = joinPaths(documentRoot, 'wp-includes/load.php'); + if (!php.fileExists(loadPhp)) return; + const content = php.readFileAsText(loadPhp); + const patched = content.replace( + "extension_loaded( 'mysqli' )", + "function_exists( 'mysqli_connect' )" + ); + if (patched !== content) { + await php.writeFile(loadPhp, patched); + } +} + +function readOnDiskWpVersion(php: PHP, documentRoot: string): string | null { + const versionPhp = joinPaths(documentRoot, 'wp-includes/version.php'); + if (!php.fileExists(versionPhp)) return null; + const content = php.readFileAsText(versionPhp); + const match = content.match(/\$wp_version\s*=\s*['"]([^'"]+)['"]/); + return match ? match[1] : null; +} + +/** + * PHP error_reporting mask for legacy WordPress: all errors EXCEPT + * E_DEPRECATED (8192) and E_STRICT (2048). Old WordPress class + * declarations (e.g. Walker_Page) trigger E_STRICT during compile; + * masking it keeps install and bootstrap output clean. + * + * Keep the two representations below in sync: on PHP 5.2, E_ALL is + * 0x7fff (before E_STRICT was folded into E_ALL in PHP 5.4), so + * `0x7fff & ~8192 & ~2048` is the numeric equivalent of the PHP + * expression `E_ALL & ~8192 & ~2048`. + */ +export const LEGACY_WP_ERROR_REPORTING_VALUE = 0x7fff & ~8192 & ~2048; +export const LEGACY_WP_ERROR_REPORTING_PHP_EXPR = 'E_ALL & ~8192 & ~2048'; + +/** + * Patches WordPress source files for legacy version compatibility. + * + * Applies all necessary patches to make old WordPress versions + * (1.0 through 2.8) work with modern PHP and the SQLite integration. + * + * Called from legacy-wp/legacy-boot.ts; legacy boot path only. + */ +export async function patchWordPressSourceFiles( + php: PHP, + documentRoot: string +) { + await ensureVersionPhp(php, documentRoot); + await ensureWpLoadPhp(php, documentRoot); + + // Version-agnostic patches. Each one's match pattern is narrow + // enough to be a no-op on WP versions that don't need it. + await patchWpSettingsPhp(php, documentRoot); + await patchWpInstallPhp(php, documentRoot); + await patchWpDbPhp(php, documentRoot); + await patchWpSchemaPhp(php, documentRoot); + await patchWpAdminRelativePaths(php, documentRoot); + await patchWpLoginDisable1Password(php, documentRoot); + await patchErrorReportingInWpLoad(php, documentRoot); + await patchWpInstallMailCrash(php, documentRoot); + + // Version-gated patches. If version.php is missing or unparseable, + // skip them all rather than guessing. + const wpVersionString = readOnDiskWpVersion(php, documentRoot); + if (wpVersionString === null) return; + const wpVersion = parseFloat(wpVersionString); + if (!Number.isFinite(wpVersion)) return; + + if (wpVersion < 1.2) { + await patchWp10DoubleQuotedSqlLiterals(php, documentRoot); + await patchWp10LoginPlaintextCompare(php, documentRoot); + } + if (wpVersion < 1.5) { + await patchWp10AdminLogoLink(php, documentRoot); + } + if (1.5 <= wpVersion && wpVersion < 2.0) { + await patchWpAdminDashboard(php, documentRoot); + } + if (wpVersion < 2.0) { + await patchWp10EditPhpPostTitleLinks(php, documentRoot); + await patchWpFunctionsPhp(php, documentRoot); + } + if (2.1 <= wpVersion && wpVersion < 2.3) { + await patchWp21PluginsPhpInArray(php, documentRoot); + } + if (wpVersion < 2.5) { + await patchCheckAdminReferer(php, documentRoot); + } + if (wpVersion < 2.8) { + await patchAdminAuthRedirect(php, documentRoot); + await patchAdminAjaxAuth(php, documentRoot); + } + if (2.9 <= wpVersion && wpVersion < 3.6) { + await patchAdminNetworkCalls(php, documentRoot); + } + if (3.3 <= wpVersion && wpVersion < 3.4) { + await patchWp33ScreenPhpSelfThis(php, documentRoot); + } + if (wpVersion >= 4.7) { + await patchWp47ThemeSearchForms(php, documentRoot); + } +} + +/** + * Removes theme `searchform.php` templates on WP 4.7+ so + * `get_search_form()` falls back to its inline HTML builder. Including + * a theme template via ob_start/require triggers an `unreachable` WASM + * trap on the PHP 5.2 binary that the runtime cannot recover from. + */ +async function patchWp47ThemeSearchForms(php: PHP, documentRoot: string) { + const themesDir = joinPaths(documentRoot, 'wp-content/themes'); + if (!php.isDir(themesDir)) return; + + for (const theme of php.listFiles(themesDir)) { + const searchformPath = joinPaths(themesDir, theme, 'searchform.php'); + if (php.fileExists(searchformPath)) { + php.unlink(searchformPath); + } + } +} + +/** + * Short-circuits dashboard RSS widgets, admin_init update hooks, and + * SimplePie's HTTP fetcher on WP 2.9–3.5. fsockopen/cURL are already + * disabled, but the surrounding HTTP machinery still touches stream + * APIs that the PHP 5.2 WASM binary cannot tolerate. + */ +async function patchAdminNetworkCalls(php: PHP, documentRoot: string) { + const dashPath = joinPaths(documentRoot, 'wp-admin/includes/dashboard.php'); + if (php.fileExists(dashPath)) { + let dash = php.readFileAsText(dashPath); + if ( + dash.includes('function wp_dashboard_primary()') && + !dash.includes('/* pg_no_rss */') + ) { + for (const fn of [ + 'wp_dashboard_primary', + 'wp_dashboard_secondary', + 'wp_dashboard_plugins', + ]) { + dash = dash.replace( + new RegExp(`function ${fn}\\(\\)\\s*\\{`), + `function ${fn}() { /* pg_no_rss */ return;` + ); + } + await php.writeFile(dashPath, dash); + } + } + + const adminPhpPath = joinPaths(documentRoot, 'wp-admin/admin.php'); + if (php.fileExists(adminPhpPath)) { + let admin = php.readFileAsText(adminPhpPath); + if ( + admin.includes("do_action('admin_init');") && + !admin.includes('/* pg_admin_init_cleanup */') + ) { + admin = admin.replace( + "do_action('admin_init');", + `/* pg_admin_init_cleanup */ +if (function_exists('remove_action')) { + @remove_action('admin_init', '_maybe_update_plugins'); + @remove_action('admin_init', '_maybe_update_themes'); + @remove_action('admin_init', '_maybe_update_core'); + @remove_action('admin_init', 'wp_version_check'); + @remove_action('admin_init', 'wp_update_plugins'); + @remove_action('admin_init', 'wp_update_themes'); +} +do_action('admin_init');` + ); + await php.writeFile(adminPhpPath, admin); + } + } + + const adminUpdatePath = joinPaths( + documentRoot, + 'wp-admin/includes/update.php' + ); + if (php.fileExists(adminUpdatePath)) { + let adminUpdate = php.readFileAsText(adminUpdatePath); + if (!adminUpdate.includes('/* pg_admin_no_updates */')) { + for (const fn of [ + 'wp_plugin_update_rows', + 'wp_plugin_update_row', + 'wp_theme_update_rows', + 'wp_theme_update_row', + 'wp_update_plugins', + 'wp_update_themes', + ]) { + const pattern = new RegExp( + `function ${fn}\\s*\\([^)]*\\)\\s*\\{` + ); + if (pattern.test(adminUpdate)) { + adminUpdate = adminUpdate.replace( + pattern, + (m) => m + ` /* pg_admin_no_updates */ return;` + ); + } + } + await php.writeFile(adminUpdatePath, adminUpdate); + } + } + + for (const spPath of [ + joinPaths(documentRoot, 'wp-includes/SimplePie/File.php'), + joinPaths(documentRoot, 'wp-includes/class-simplepie.php'), + ]) { + if (!php.fileExists(spPath)) continue; + let sp = php.readFileAsText(spPath); + if ( + sp.includes('function SimplePie_File(') && + !sp.includes('/* pg_no_fetch */') + ) { + sp = sp.replace( + /function SimplePie_File\([^)]*\)\s*\{/, + (m) => + m + + `\n\t\t/* pg_no_fetch */\n\t\t$this->error = 'Network requests disabled in Playground';\n\t\t$this->success = false;\n\t\treturn;` + ); + await php.writeFile(spPath, sp); + } + } +} + +/** + * No-ops the install-time pretty-permalink HTTP probe so the PHP 5.2 + * WASM binary doesn't trap inside wp_remote_get(); the probe just + * times out anyway. wp_mail() is no longer patched: mail() is in + * LEGACY_PHP_DISABLED_NETWORK_FUNCTIONS and PHPMailer's SMTP fallback + * also needs fsockopen (also disabled), so wp_mail() now fails + * safely with a WP_Error rather than trapping. + */ +async function patchWpInstallMailCrash(php: PHP, documentRoot: string) { + // wp_check_mysql_version is left untouched: mysql_get_server_info is + // shimmed in mysql-shims.ts, so the version check now passes safely. + const noOpFunctions: Array<[string, string]> = [ + ['function wp_new_blog_notification', 'pg_no_blog_notification'], + [ + 'function wp_install_maybe_enable_pretty_permalinks', + 'pg_no_permalink_check', + ], + ]; + const upgradeFiles = [ + joinPaths(documentRoot, 'wp-admin/includes/upgrade.php'), + joinPaths(documentRoot, 'wp-admin/upgrade-functions.php'), + ]; + for (const filePath of upgradeFiles) { + if (!php.fileExists(filePath)) continue; + let content = php.readFileAsText(filePath); + let changed = false; + for (const [funcSig, marker] of noOpFunctions) { + if (content.includes(`/* ${marker} */`)) continue; + const idx = content.indexOf(funcSig); + if (idx === -1) continue; + const braceIdx = content.indexOf('{', idx); + if (braceIdx === -1) continue; + content = + content.substring(0, braceIdx + 1) + + ` /* ${marker} */ return;` + + content.substring(braceIdx + 1); + changed = true; + } + if (changed) { + await php.writeFile(filePath, content); + } + } +} + +/** + * Mask E_STRICT/E_DEPRECATED in wp-load.php — it sets error_reporting + * before wp-settings.php's matching patch runs, so both need patching. + */ +async function patchErrorReportingInWpLoad(php: PHP, documentRoot: string) { + const wpLoadPath = joinPaths(documentRoot, 'wp-load.php'); + if (!php.fileExists(wpLoadPath)) return; + const content = php.readFileAsText(wpLoadPath); + if (!content.includes('error_reporting(')) return; + if (content.includes('~8192') && content.includes('~2048')) return; + const patched = content.replace( + /error_reporting\(([^)]+)\)/g, + (_match: string, flags: string) => + `error_reporting((${flags}) & ~8192 & ~2048)` + ); + await php.writeFile(wpLoadPath, patched); +} + +/** + * Neutralises absolute `http://wordpress.org` links in WP 1.0/1.2 admin + * templates. Clicking them navigates the scoped iframe to wordpress.org, + * which `X-Frame-Options: sameorigin` then refuses to render — destroying + * the Playground frame. + */ +async function patchWp10AdminLogoLink(php: PHP, documentRoot: string) { + // WP 1.0: header logo lives in wp-admin/menu.php. + const menuPhpPath = joinPaths(documentRoot, 'wp-admin/menu.php'); + if (php.fileExists(menuPhpPath)) { + const content = php.readFileAsText(menuPhpPath); + if (!content.includes('/* pg_wp10_logo_link */')) { + const needle = + '

WordPress

'; + if (content.includes(needle)) { + const patched = content.replace( + needle, + '

WordPress

' + ); + if (patched !== content) { + await php.writeFile(menuPhpPath, patched); + } + } + } + } + + // WP 1.2: header logo lives in admin-header.php. The opening anchor + // tag contains a `?>` inside the title attribute, so [^>]* would stop + // short — splice by start/end indices instead of using a regex. + const adminHeaderPath = joinPaths( + documentRoot, + 'wp-admin/admin-header.php' + ); + if (php.fileExists(adminHeaderPath)) { + const content = php.readFileAsText(adminHeaderPath); + if (!content.includes('/* pg_wp12_logo_link */')) { + const logoStart = 'WordPress' + + content.substring(endIdx + logoEnd.length); + if (patched !== content) { + await php.writeFile(adminHeaderPath, patched); + } + } + } + } + } + + // WP 1.0 (no trailing slash) and WP 1.2 (with slash): footer badge. + const adminFooterPath = joinPaths( + documentRoot, + 'wp-admin/admin-footer.php' + ); + if (php.fileExists(adminFooterPath)) { + const content = php.readFileAsText(adminFooterPath); + if (!content.includes('/* pg_wp10_footer_link */')) { + const patched = content + .replace( + 'WordPress', + 'WordPress' + ) + .replace( + 'WordPress', + 'WordPress' + ); + if (patched !== content) { + await php.writeFile(adminFooterPath, patched); + } + } + } +} + +/** + * Rewrites post-title links in WP 1.0/1.2/1.5 `wp-admin/edit.php` so they + * open the edit form. WP 1.0/1.2 link to the front-end permalink (which + * navigates away from the admin), and WP 1.5 renders no link at all — a + * separate inline "Edit" link exists but is easy to miss. `$id` is set by + * `start_wp()` in all three versions' loops. + */ +async function patchWp10EditPhpPostTitleLinks(php: PHP, documentRoot: string) { + const editPhpPath = joinPaths(documentRoot, 'wp-admin/edit.php'); + if (!php.fileExists(editPhpPath)) return; + + const content = php.readFileAsText(editPhpPath); + if (content.includes('/* pg_wp10_post_title_edit */')) return; + + let patched = content; + + // WP 1.0: title wrapped in , href uses permalink_link(). + const needleWp10 = + ''; + if (patched.includes(needleWp10)) { + patched = patched.replace( + needleWp10, + '' + ); + } + + // WP 1.2: title in a , href uses the_permalink(). Closing is + // on a separate line; patching the opening tag is sufficient. + const needleWp12 = + ''; + if (patched.includes(needleWp12)) { + patched = patched.replace( + needleWp12, + '' + ); + } + + // WP 1.5: title is plain text — wrap it in an edit link. + const needleWp15 = + '\n' + + "\t\tpost_status) _e(' - Private'); ?>"; + if (patched.includes(needleWp15)) { + patched = patched.replace( + needleWp15, + '' + + "\n\t\tpost_status) _e(' - Private'); ?>" + ); + } + + if (patched !== content) { + await php.writeFile(editPhpPath, patched); + } +} + +/** + * Fix WP 3.3's `self::$this->_help_sidebar` typo in screen.php — PHP + * 5.3+ fatals on it whenever the sidebar is populated (e.g. post-new.php). + * WP 3.4 rewrote the method; gated by the buggy expression itself. + */ +async function patchWp33ScreenPhpSelfThis(php: PHP, documentRoot: string) { + const screenPath = joinPaths(documentRoot, 'wp-admin/includes/screen.php'); + if (!php.fileExists(screenPath)) return; + const content = php.readFileAsText(screenPath); + if (!content.includes('self::$this->_help_sidebar')) return; + const patched = content.replace( + /self::\$this->_help_sidebar/g, + '$this->_help_sidebar' + ); + if (patched !== content) { + await php.writeFile(screenPath, patched); + } +} + +/** + * Guard WP 2.1/2.2 plugins.php `in_array($plugin, $current)`: fresh + * installs return `""` from `get_option('active_plugins')` and PHP + * then warns. WP 2.0 had its own sanity block; WP 2.3+ unserializes + * and defaults to array(). + */ +async function patchWp21PluginsPhpInArray(php: PHP, documentRoot: string) { + const pluginsPath = joinPaths(documentRoot, 'wp-admin/plugins.php'); + if (!php.fileExists(pluginsPath)) return; + const content = php.readFileAsText(pluginsPath); + if (content.includes('/* pg_wp21_active_plugins_array */')) return; + const needle = "$current = get_option('active_plugins');"; + if (!content.includes(needle)) return; + const patched = content.replace( + needle, + needle + + '\n\tif (!is_array($current)) $current = array(); /* pg_wp21_active_plugins_array */' + ); + if (patched !== content) { + await php.writeFile(pluginsPath, patched); + } +} + +/** + * Teaches WP 1.0's `wp-login.php` to accept an md5-hashed `user_pass`. + * WP 1.0 compares submitted passwords to `user_pass` in plaintext, but + * Playground seeds every legacy admin row with `MD5('password')` to match + * the cookie-auth format WP 1.2+ and the auto-login mu-plugin expect. + * Without this patch the manual login form rejects the seeded admin. + * + * Scoped via the exact plaintext SQL fragment, which WP 1.2 removed when + * `wp_login()` moved into `wp-includes/functions.php`. + */ +async function patchWp10LoginPlaintextCompare(php: PHP, documentRoot: string) { + const loginPath = joinPaths(documentRoot, 'wp-login.php'); + if (!php.fileExists(loginPath)) return; + const content = php.readFileAsText(loginPath); + const sqlMarker = "AND user_pass = '$password'"; + if (!content.includes(sqlMarker)) return; + if (content.includes('pg_wp10_plain_or_md5')) return; + let patched = content.replace( + sqlMarker, + "AND (user_pass = '$password' OR user_pass = MD5('$password')) /* pg_wp10_plain_or_md5 */" + ); + patched = patched.replace( + '$login->user_pass == $password', + '($login->user_pass == $password || $login->user_pass == md5($password))' + ); + if (patched !== content) { + await php.writeFile(loginPath, patched); + } +} + +// ── Private helpers ────────────────────────────────────────────── + +/** WP < 1.5 lacks wp-includes/version.php. Create a stub. */ +async function ensureVersionPhp(php: PHP, documentRoot: string) { + const wpIncludesDir = joinPaths(documentRoot, 'wp-includes'); + if (!php.isDir(wpIncludesDir)) return; + const versionPhpPath = joinPaths(wpIncludesDir, 'version.php'); + if (!php.fileExists(versionPhpPath)) { + await php.writeFile(versionPhpPath, ` + flags.includes('~8192') && flags.includes('~2048') + ? match + : `error_reporting((${flags}) & ~8192 & ~2048)` + ); + + // set_magic_quotes_runtime removed in PHP 7.0. + settings = settings.replace( + /set_magic_quotes_runtime\(\s*0\s*\)\s*;/g, + '// set_magic_quotes_runtime(0); // Removed' + ); + + // get_magic_quotes_gpc removed in PHP 8.0. + if (!settings.includes("function_exists('get_magic_quotes_gpc')")) { + settings = settings.replace( + /get_magic_quotes_gpc\(\)/g, + "(function_exists('get_magic_quotes_gpc') && get_magic_quotes_gpc())" + ); + } + + // "=& new" triggers compile-time E_DEPRECATED in PHP 5.3+. + settings = settings.replace(/=\s*&\s*new\b/g, '= new'); + + // $HTTP_SERVER_VARS removed in PHP 5.4. + settings = settings.replace(/\$HTTP_SERVER_VARS/g, '$_SERVER'); + + // WP < 2.0 has no WP_CONTENT_DIR; the SQLite db.php drop-in needs it. + if ( + !settings.includes('WP_CONTENT_DIR') && + settings.includes("define('WPINC'") + ) { + settings = settings.replace( + /define\('WPINC',\s*'wp-includes'\);/, + `define('WPINC', 'wp-includes');\nif (!defined('WP_CONTENT_DIR')) define('WP_CONTENT_DIR', ABSPATH . 'wp-content');` + ); + } + + // WP 2.5–3.x unsets $wp_filter to defeat register_globals — that + // also wipes hooks our auto_prepend_file preload registered (e.g. + // playground_load_mu_plugins). Drop $wp_filter from the unset list. + settings = settings.replace(/unset\(\s*\$wp_filter\s*,/, 'unset('); + + settings = removeNotInstalledDie(settings); + settings = injectInitHookCleanup(settings); + + if (settings !== original) { + await php.writeFile(wpSettingsPath, settings); + } +} + +/** + * Removes the WP 1.x–2.x "you haven't installed WP yet" die(). The + * call may be wrapped in sprintf/__/etc., so we match by locating + * "installed WP" and walking back to the enclosing die(...); + */ +function removeNotInstalledDie(settings: string): string { + const instIdx = settings.indexOf('installed WP'); + if (instIdx === -1) return settings; + const dieStart = settings.lastIndexOf('die(', instIdx); + if (dieStart === -1) return settings; + + let depth = 0; + for (let i = dieStart + 3; i < settings.length; i++) { + if (settings[i] === '(') depth++; + else if (settings[i] === ')') { + depth--; + if (depth === 0) { + let dieEnd = i + 1; + if (settings[dieEnd] === ';') dieEnd++; + return ( + settings.substring(0, dieStart) + + 'true; /* die removed by Playground */' + + settings.substring(dieEnd) + ); + } + } + } + return settings; +} + +/** + * Strips network-calling hooks and disables HTTP transports right + * before do_action('init') in wp-settings.php. WP 2.5–2.7 wires + * wp_cron/wp_version_check/etc. into 'init' and 'admin_init'; their + * fsockopen/cURL paths trigger "null function or function signature + * mismatch" WASM traps on the PHP 5.2 binary. WP 3.2+ honors the + * use_*_transport filters as a second line of defense. + */ +function injectInitHookCleanup(settings: string): string { + return settings.replace( + "do_action('init');", + `// Remove hooks that make outbound HTTP requests (crash WASM). +if (function_exists('remove_action')) { + @remove_action('init', 'wp_cron'); + @remove_action('init', 'wp_version_check'); + @remove_action('init', 'wp_update_plugins'); + @remove_action('init', 'wp_update_themes'); + @remove_action('admin_init', '_maybe_update_plugins'); + @remove_action('admin_init', '_maybe_update_themes'); + @remove_action('admin_init', 'wp_version_check'); + @remove_action('admin_init', 'wp_update_plugins'); + @remove_action('admin_init', 'wp_update_themes'); + @remove_action('load-plugins.php', 'wp_update_plugins'); + @remove_action('load-update.php', 'wp_update_plugins'); + @remove_action('load-update.php', 'wp_update_themes'); + @remove_action('load-themes.php', 'wp_update_themes'); + @remove_action('wp_update_plugins', 'wp_update_plugins'); + @remove_action('wp_version_check', 'wp_version_check'); +} +if (function_exists('add_filter')) { + function _pg_disable_curl() { return false; } + function _pg_disable_streams() { return false; } + @add_filter('use_curl_transport', '_pg_disable_curl'); + @add_filter('use_streams_transport', '_pg_disable_streams'); + @add_filter('use_ftp_transport', '_pg_disable_curl'); + @add_filter('use_fsockopen_transport', '_pg_disable_streams'); +} +do_action('init');` + ); +} + +async function patchWpFunctionsPhp(php: PHP, documentRoot: string) { + const functionsPhpPath = joinPaths( + documentRoot, + 'wp-includes/functions.php' + ); + if (!php.fileExists(functionsPhpPath)) return; + + let functionsPhp = php.readFileAsText(functionsPhpPath); + let functionsPhpChanged = false; + + // WP 1.5 writes `$all_options->{$option->option_name}` without first + // initialising `$all_options`; PHP 5.3+ warns and the cache stays empty. + if ( + functionsPhp.includes('$all_options->{$option->option_name}') && + !functionsPhp.includes('$all_options = new stdClass') + ) { + functionsPhp = functionsPhp.replace( + 'foreach ($options as $option) {', + '$all_options = new stdClass;\n\tforeach ($options as $option) {' + ); + functionsPhpChanged = true; + } + + if (functionsPhpChanged) { + await php.writeFile(functionsPhpPath, functionsPhp); + } +} + +/** + * Patches wp-admin/install.php for old WP versions. The legacy boot + * flow itself bypasses install.php (see runLegacyInstaller in + * legacy-boot.ts), but a user can still navigate to /wp-admin/install.php + * manually — these patches keep the page loadable rather than fataling + * at parse/include time on PHP 5.2+. + */ +async function patchWpInstallPhp(php: PHP, documentRoot: string) { + const installPhpPath = joinPaths(documentRoot, 'wp-admin/install.php'); + if (!php.fileExists(installPhpPath)) return; + + const original = php.readFileAsText(installPhpPath); + let installPhp = original; + + // WP 1.x–2.5 use relative require paths that break when CWD isn't + // wp-admin/ (Playground's CWD is the document root). + const absAdminDir = joinPaths(documentRoot, 'wp-admin'); + installPhp = installPhp + .replace(/'\.\.\/(wp-config\.php)'/g, `'${documentRoot}/$1'`) + .replace(/'\.\.\/(wp-load\.php)'/g, `'${documentRoot}/$1'`) + .replace(/'\.\/(upgrade-functions\.php)'/g, `'${absAdminDir}/$1'`) + .replace(/'(upgrade-functions\.php)'/g, `'${absAdminDir}/$1'`) + .replace(/'\.\/(includes\/upgrade\.php)'/g, `'${absAdminDir}/$1'`) + .replace(/'\.\.\/(wp-includes\/[^']+)'/g, `'${documentRoot}/$1'`); + + // $HTTP_GET_VARS/$HTTP_POST_VARS removed in PHP 5.4. + installPhp = installPhp + .replace(/\$HTTP_GET_VARS/g, '$_GET') + .replace(/\$HTTP_POST_VARS/g, '$_POST'); + + if (installPhp !== original) { + await php.writeFile(installPhpPath, installPhp); + } +} + +/** + * Patches wp-includes/wp-db.php so old wpdb classes (WP 1.5–2.5) can + * delegate to WP_SQLite_DB and expose the methods that newer WP + * callers (and the SQLite drop-in) expect. + */ +async function patchWpDbPhp(php: PHP, documentRoot: string) { + const wpDbPath = joinPaths(documentRoot, 'wp-includes/wp-db.php'); + if (!php.fileExists(wpDbPath)) return; + + const original = php.readFileAsText(wpDbPath); + let wpDb = original; + + // The SQLite db.php drop-in instantiates $wpdb itself; guard the + // global write so wp-db.php doesn't overwrite the lazy loader. + if (!wpDb.includes('isset($wpdb)')) { + wpDb = wpDb.replace( + '$wpdb = new wpdb(DB_USER, DB_PASSWORD, DB_NAME, DB_HOST);', + 'if ( !isset($wpdb) ) { $wpdb = new wpdb(DB_USER, DB_PASSWORD, DB_NAME, DB_HOST); }' + ); + } + + // WP < 3.0 calls mysql_connect() inline in the constructor; the + // SQLite-backed wpdb subclass exposes db_connect() instead. + if (!wpDb.includes('db_connect')) { + wpDb = wpDb.replace( + /\$this->dbh\s*=\s*@mysql_connect\(\$dbhost\s*,\s*\$dbuser\s*,\s*\$dbpassword(?:\s*,\s*true)?\);/, + 'if (method_exists($this, "db_connect")) { $this->dbname = $dbname; $this->db_connect(); } else { $this->dbh = @mysql_connect($dbhost, $dbuser, $dbpassword); }' + ); + } + + wpDb = injectWpdbPolyfills(wpDb); + + if (wpDb !== original) { + await php.writeFile(wpDbPath, wpDb); + } +} + +/** + * Injects polyfill methods into the wpdb class. WP 1.5–2.4 ship a + * minimal wpdb (no set_prefix, init_charset, check_connection, etc.), + * but the SQLite drop-in and WP_SQLite_DB call these methods + * unconditionally. + */ +function injectWpdbPolyfills(wpDb: string): string { + const polyfills: string[] = []; + if (!wpDb.includes('function set_prefix')) { + polyfills.push(` + function set_prefix($prefix) { + $this->prefix = $prefix; + $tables = array('posts', 'users', 'categories', 'post2cat', 'comments', 'link2cat', 'links', 'options', 'postmeta', 'usermeta', 'terms', 'term_taxonomy', 'term_relationships'); + foreach ($tables as $t) { + $this->$t = $prefix . $t; + } + return $prefix; + }`); + } + if (!wpDb.includes('function timer_start')) { + polyfills.push(` + function timer_start() { + $this->time_start = microtime(true); + return true; + }`); + } + if (!wpDb.includes('function timer_stop')) { + polyfills.push(` + function timer_stop() { + return microtime(true) - $this->time_start; + }`); + } + if (!wpDb.includes('function init_charset')) { + polyfills.push(` + function init_charset() { + if (defined('DB_CHARSET')) $this->charset = DB_CHARSET; + if (defined('DB_COLLATE')) $this->collate = DB_COLLATE; + }`); + } + if (!wpDb.includes('function bail')) { + polyfills.push(` + function bail($message, $error_code = '500') { + die($message); + }`); + } + if (!wpDb.includes('function check_connection')) { + polyfills.push(` + function check_connection($allow_bail = true) { + return true; + }`); + } + if (polyfills.length === 0) return wpDb; + + const classEndMatch = wpDb.match( + /^(\s*})\s*\n+(\$wpdb|\?>\s*$|if\s*\(\s*!\s*isset\(\s*\$wpdb\s*\))/m + ); + if (!classEndMatch || classEndMatch.index === undefined) return wpDb; + + const polyfillBlock = + '\n\t// Polyfills added by WordPress Playground.\n' + + polyfills.join('\n') + + '\n\n'; + return ( + wpDb.substring(0, classEndMatch.index) + + polyfillBlock + + wpDb.substring(classEndMatch.index) + ); +} + +/** + * Fixes relative paths in wp-admin files so they work regardless of CWD. + * + * Old WordPress (< 3.7) uses relative paths like `require('../wp-load.php')`, + * `require('./admin.php')`, and `include('./admin-footer.php')` in wp-admin + * scripts. These fail in the Playground because PHP's CWD is set to the + * document root, not the script's directory. Modern WordPress uses + * `dirname(__FILE__)` instead. + */ +async function patchWpAdminRelativePaths(php: PHP, documentRoot: string) { + // CWD during a Playground request is the document root, so + // require/include statements with './' or '../' resolve relative to + // /wordpress instead of the file's own directory. Rewrite every + // relative require/include in wp-admin to a dirname(__FILE__)-based + // absolute path. Covers WP 1.2 through 3.6. + const toDirnameExpr = (relPath: string): string => { + let remaining = relPath; + let upLevels = 0; + while (remaining.startsWith('../')) { + upLevels++; + remaining = remaining.slice(3); + } + while (remaining.startsWith('./')) { + remaining = remaining.slice(2); + } + let dirExpr = 'dirname(__FILE__)'; + for (let i = 0; i < upLevels; i++) { + dirExpr = `dirname(${dirExpr})`; + } + return `${dirExpr} . '/${remaining}'`; + }; + const wpAdminDir = joinPaths(documentRoot, 'wp-admin'); + if (php.isDir(wpAdminDir)) { + for (const file of php.listFiles(wpAdminDir)) { + if (!file.endsWith('.php')) continue; + const filePath = joinPaths(wpAdminDir, file); + const content = php.readFileAsText(filePath); + const patched = content + .replace( + /((?:require|include)(?:_once)?)\s*\(\s*(['"])(\.\.\/[^'"]+)\2\s*\)/g, + (_, keyword, _q, path) => + `${keyword}(${toDirnameExpr(path)})` + ) + .replace( + /((?:require|include)(?:_once)?)\s*\(\s*(['"])(\.\/[^'"]+)\2\s*\)/g, + (_, keyword, _q, path) => + `${keyword}(${toDirnameExpr(path)})` + ) + // Bare filename (e.g. 'admin-header.php'). Restrict to + // .php to avoid false positives. + .replace( + /((?:require|include)(?:_once)?)\s*\(\s*(['"])([a-z][\w-]*\.php)\2\s*\)/g, + (_, keyword, _q, path) => + `${keyword}(${toDirnameExpr(path)})` + ) + // Statement form without parentheses (WP 2.0 uses this). + .replace( + /((?:require|include)(?:_once)?)\s+(['"])(\.\.\/[^'"]+)\2/g, + (_, keyword, _q, path) => + `${keyword}(${toDirnameExpr(path)})` + ) + .replace( + /((?:require|include)(?:_once)?)\s+(['"])(\.\/[^'"]+)\2/g, + (_, keyword, _q, path) => + `${keyword}(${toDirnameExpr(path)})` + ) + .replace( + /((?:require|include)(?:_once)?)\s+(['"])([a-z][\w-]*\.php)\2/g, + (_, keyword, _q, path) => + `${keyword}(${toDirnameExpr(path)})` + ) + // Drop the leading slash from `ABSPATH . '/wp-...'`. + .replace(/ABSPATH\s*\.\s*'\/wp-/g, "ABSPATH . 'wp-"); + if (patched !== content) { + await php.writeFile(filePath, patched); + } + } + } + + // WP 1.2: index.php redirects using get_settings('siteurl') which + // may be 'http://localhost' (wrong host for the Playground). Replace + // with relative redirects that work regardless of siteurl. + const indexPhpPath = joinPaths(documentRoot, 'wp-admin/index.php'); + if (php.fileExists(indexPhpPath)) { + let indexPhp = php.readFileAsText(indexPhpPath); + if (indexPhp.includes("get_settings('siteurl')")) { + indexPhp = indexPhp.replace( + /get_settings\('siteurl'\)\s*\.\s*'\/wp-admin\//g, + "'" + ); + await php.writeFile(indexPhpPath, indexPhp); + } + } + + // WP 1.0.2 wp-admin/menu.php reads the admin menu definition from + // a relative path: `$menu = file('./menu.txt');`. The CWD during + // a Playground request is the document root (/wordpress), not + // wp-admin, so ./menu.txt resolves to /wordpress/menu.txt and + // fails. Rewrite to an absolute path relative to the menu.php + // file location. + const menuPhpPath = joinPaths(documentRoot, 'wp-admin/menu.php'); + if (php.fileExists(menuPhpPath)) { + const menuPhp = php.readFileAsText(menuPhpPath); + const needle = `file('./menu.txt')`; + if (menuPhp.includes(needle)) { + await php.writeFile( + menuPhpPath, + menuPhp.replace(needle, `file(dirname(__FILE__) . '/menu.txt')`) + ); + } + } +} + +/** + * Bypasses referer-based check_admin_referer() in WP < 2.5. The + * Referer header is unreliable inside Playground's service worker, so + * the original die() short-circuits plugin activation and other admin + * actions. WP 2.5+ uses nonces and doesn't need this patch. + */ +async function patchCheckAdminReferer(php: PHP, documentRoot: string) { + const adminFunctionsPath = joinPaths( + documentRoot, + 'wp-admin/admin-functions.php' + ); + if (!php.fileExists(adminFunctionsPath)) return; + + const content = php.readFileAsText(adminFunctionsPath); + if ( + !content.includes('function check_admin_referer()') || + !content.includes("$_SERVER['HTTP_REFERER']") + ) { + return; + } + + const patched = replacePhpFunctionBody( + content, + 'check_admin_referer', + `\n\tdo_action('check_admin_referer', '');\n` + ); + if (patched !== content) { + await php.writeFile(adminFunctionsPath, patched); + } +} + +/** + * Replaces the body of a top-level PHP function `fnName` (zero-arg) + * with `newBody` using a balanced-brace walker. Returns the original + * string if the function isn't found. + */ +function replacePhpFunctionBody( + source: string, + fnName: string, + newBody: string +): string { + const sig = `function ${fnName}()`; + const sigIdx = source.indexOf(sig); + if (sigIdx === -1) return source; + const openIdx = source.indexOf('{', sigIdx + sig.length); + if (openIdx === -1) return source; + + let depth = 1; + for (let i = openIdx + 1; i < source.length; i++) { + const ch = source[i]; + if (ch === '{') depth++; + else if (ch === '}') { + depth--; + if (depth === 0) { + return ( + source.substring(0, openIdx + 1) + + newBody + + source.substring(i) + ); + } + } + } + return source; +} + +/** + * Removes WP 1.5's `AND post_date_gmt < '$today'` from the dashboard + * recent-posts query: SQLite mishandles the comparison against the + * '0000-00-00 00:00:00' values seeded by the legacy installer, leaving + * the dashboard empty. The post_status='publish' filter alone is + * enough — scheduled posts use status 'future' (WP 2.1+) or aren't + * published yet (WP 1.x). + */ +async function patchWpAdminDashboard(php: PHP, documentRoot: string) { + const indexPhpPath = joinPaths(documentRoot, 'wp-admin/index.php'); + if (php.fileExists(indexPhpPath)) { + const content = php.readFileAsText(indexPhpPath); + const patched = content.replace(/AND post_date_gmt < '\$today'/, ''); + if (patched !== content) { + await php.writeFile(indexPhpPath, patched); + } + } + + await patchRssFunctionsErrorStub(php, documentRoot); +} + +/** + * WP 1.5's Magpie RSS library calls a bare `error()` function from + * fetch_rss() / _response_to_rss(), but `error()` only exists as a + * method on the RSSCache class. RSS fetches always fail in Playground + * (no outbound HTTP), so without a global stub the dashboard dies on + * "Call to undefined function error()". + */ +async function patchRssFunctionsErrorStub(php: PHP, documentRoot: string) { + const rssPath = joinPaths(documentRoot, 'wp-includes/rss-functions.php'); + if (!php.fileExists(rssPath)) return; + + let content = php.readFileAsText(rssPath); + if ( + !/^\s*error\s*\(/m.test(content) || + /^function\s+error\s*\(/m.test(content) + ) { + return; + } + + content = content.replace( + /^(<\?php\s*)/, + `$1\n` + + `if (!function_exists('error')) {\n` + + `\tfunction error($msg = '', $lvl = E_USER_WARNING) {\n` + + `\t\tif (defined('MAGPIE_DEBUG') && MAGPIE_DEBUG) {\n` + + `\t\t\ttrigger_error($msg, $lvl);\n` + + `\t\t}\n` + + `\t}\n` + + `}\n` + ); + await php.writeFile(rssPath, content); +} + +/** + * Disables 1Password's inline autofill on legacy wp-login.php. The inline + * tooltip enters a tight inject/remove loop inside Playground's sandboxed + * iframes, flickering the UI and emitting thousands of extension-CSS + * fetches per second. `data-1p-ignore` is 1Password's official opt-out. + */ +async function patchWpLoginDisable1Password(php: PHP, documentRoot: string) { + const loginPath = joinPaths(documentRoot, 'wp-login.php'); + if (!php.fileExists(loginPath)) return; + + let content = php.readFileAsText(loginPath); + let changed = false; + + for (const fieldName of ['log', 'pwd']) { + // Match the field's name attribute only when the surrounding tag + // (everything up to the next `>`) does not already carry the opt-out. + const re = new RegExp( + `(\\bname=(['"])${fieldName}\\2)(?![^>]*data-1p-ignore)` + ); + if (re.test(content)) { + content = content.replace(re, '$1 data-1p-ignore'); + changed = true; + } + } + + if (changed) { + await php.writeFile(loginPath, content); + } +} + +/** + * Injects auth-cookie population before `auth_redirect()` in + * `wp-admin/admin.php` (WP 2.0-2.7) and replaces `wp-admin/auth.php` + * with a stub that pre-populates user globals (WP 1.2). WP < 2.8 has + * no mu-plugin support, so the auto-login mu-plugin can't run. + */ +async function patchAdminAuthRedirect(php: PHP, documentRoot: string) { + // Session tokens don't exist until WP 4.0, so cookies generated + // here can't mismatch a token. Nonces in WP < 4.0 only depend on + // user ID, action, and secret keys. + const adminPhpPath = joinPaths(documentRoot, 'wp-admin/admin.php'); + if (php.fileExists(adminPhpPath)) { + const content = php.readFileAsText(adminPhpPath); + if (content.includes('auth_redirect()')) { + const authCode = ` +// Playground: populate auth cookies and force admin user before auth_redirect. +if (defined('PLAYGROUND_AUTO_LOGIN_AS_USER')) { + if (function_exists('is_user_logged_in') && is_user_logged_in()) { + // On WP < 4.0, wp_set_auth_cookie() does not update $_COOKIE + // in-process — auth_redirect() reads $_COOKIE, so re-emit. + if (function_exists('wp_generate_auth_cookie') && defined('LOGGED_IN_COOKIE') && empty($_COOKIE[LOGGED_IN_COOKIE])) { + $_pg_uid = wp_get_current_user()->ID; + $_pg_exp = time() + 172800; + $_COOKIE[AUTH_COOKIE] = wp_generate_auth_cookie($_pg_uid, $_pg_exp, 'auth'); + if (defined('SECURE_AUTH_COOKIE')) + $_COOKIE[SECURE_AUTH_COOKIE] = wp_generate_auth_cookie($_pg_uid, $_pg_exp, 'secure_auth'); + $_COOKIE[LOGGED_IN_COOKIE] = wp_generate_auth_cookie($_pg_uid, $_pg_exp, 'logged_in'); + } + } else { + ${legacyAuthCookieBlock('PLAYGROUND_AUTO_LOGIN_AS_USER')} + // WP 2.0-2.4: kses_init() runs during do_action('init') inside + // wp-settings.php and caches $current_user as WP_User(0) when + // no cookies were set yet. Reset and re-evaluate so capability + // checks see the user we just authenticated. + if (!function_exists('wp_generate_auth_cookie')) { + $GLOBALS['current_user'] = null; + if (function_exists('get_currentuserinfo')) { + get_currentuserinfo(); + } + } + } + // Force admin caps in-memory: if populate_roles() never ran + // (e.g. WP 2.0, or WP 2.5 installs that crashed before writing + // roles), the user has no caps and every current_user_can() fails. + $_pg_cu = isset($GLOBALS['current_user']) ? $GLOBALS['current_user'] : null; + if ($_pg_cu && isset($_pg_cu->ID) && $_pg_cu->ID > 0 && empty($_pg_cu->allcaps['read'])) { + // Respect a DB-stored user_level so a blueprint that auto-logs + // in as a lower-privilege user doesn't silently get level 10. + $_pg_db_level = isset($_pg_cu->user_level) + ? (int) $_pg_cu->user_level + : null; + if ($_pg_db_level === null && isset($_pg_user) && $_pg_user) { + $_pg_db_level = isset($_pg_user->user_level) + ? (int) $_pg_user->user_level + : null; + } + $_pg_cu->user_level = $_pg_db_level !== null ? $_pg_db_level : 10; + $_pg_effective_level = $_pg_cu->user_level; + $_pg_caps = array('read'); + for ($_pg_i = 0; $_pg_i <= $_pg_effective_level; $_pg_i++) { + $_pg_caps[] = 'level_' . $_pg_i; + } + if ($_pg_effective_level >= 10) { + $_pg_caps = array_merge($_pg_caps, array( + 'switch_themes','edit_themes','activate_plugins', + 'edit_plugins','edit_users','edit_files','manage_options', + 'moderate_comments','manage_categories','manage_links', + 'upload_files','import','unfiltered_html','edit_posts', + 'edit_others_posts','edit_published_posts','publish_posts', + 'edit_pages')); + } + foreach ($_pg_caps as $_pg_c) { + $_pg_cu->allcaps[$_pg_c] = true; + } + if ($_pg_effective_level >= 10) { + $_pg_cu->caps = array('administrator' => true); + } + } +} +`; + const patched = content.replace( + 'auth_redirect();', + authCode + 'auth_redirect();' + ); + if (patched !== content) { + await php.writeFile(adminPhpPath, patched); + } + } + } + + // WP 1.2 routes admin auth through wp-admin/auth.php (no admin.php + // / auth_redirect). The original auth.php calls wp_login()/veriflog() + // with cookie values Playground can't reliably reproduce: the pass + // cookie is md5 of the stored pw and get_settings('siteurl') is not + // stable during install. Short-circuit by setting cookies AND + // populating the user globals get_currentuserinfo() would have set. + const authPhpPath = joinPaths(documentRoot, 'wp-admin/auth.php'); + if (php.fileExists(authPhpPath)) { + const authPhp = php.readFileAsText(authPhpPath); + if ( + authPhp.includes('$cookiehash') && + !authPhp.includes('Playground: bypass auth') + ) { + // WP 1.2 defines both `$cookiehash` and the COOKIEHASH + // constant; WP 1.0 only the variable. Check both, with a + // siteurl-derived fallback. + const bypassedAuth = `user_level) + ? (int) $__pg_userdata->user_level + : 10; + $user_ID = $__pg_userdata->ID; + $user_nickname = isset($__pg_userdata->user_nickname) + ? $__pg_userdata->user_nickname + : $__pg_user_login; + $user_email = isset($__pg_userdata->user_email) + ? $__pg_userdata->user_email + : ''; + $user_url = isset($__pg_userdata->user_url) + ? $__pg_userdata->user_url + : ''; + $user_pass_md5 = md5( + isset($__pg_userdata->user_pass) ? $__pg_userdata->user_pass : '' + ); + } +} +?>`; + if (bypassedAuth !== authPhp) { + await php.writeFile(authPhpPath, bypassedAuth); + } + } + } +} + +/** + * Injects auth-cookie population before the `is_user_logged_in()` + * gate in `wp-admin/admin-ajax.php` for WP 2.5-2.7. admin-ajax.php + * loads wp-config.php directly (not via admin.php), and WP < 2.8 has + * no mu-plugin support, so no other auth mechanism applies here. + */ +async function patchAdminAjaxAuth(php: PHP, documentRoot: string) { + const ajaxPhpPath = joinPaths(documentRoot, 'wp-admin/admin-ajax.php'); + if (!php.fileExists(ajaxPhpPath)) return; + + let content = php.readFileAsText(ajaxPhpPath); + if (!content.includes('is_user_logged_in')) return; + + const authCode = ` +// Playground: authenticate admin user for AJAX requests on WP < 2.8. +if (defined('PLAYGROUND_AUTO_LOGIN_AS_USER')) { + ${legacyAuthCookieBlock('PLAYGROUND_AUTO_LOGIN_AS_USER')} +} +`; + + content = content.replace( + /if\s*\(\s*!\s*is_user_logged_in\(\)\s*\)/, + authCode + 'if ( !is_user_logged_in() )' + ); + await php.writeFile(ajaxPhpPath, content); +} + +/** + * PHP snippet that, on WP 2.5+, resolves a username to a WP_User and + * populates `$_COOKIE` with the three HMAC auth cookies. + * + * On WP < 2.5 the block is a no-op: env.php's + * {@link playground_legacy_set_auth_cookies_early} runs via + * auto_prepend_file before every script and already populated the + * `wordpressuser_$cookiehash` / `wordpresspass_$cookiehash` pair + * (which also backs the USER_COOKIE/PASS_COOKIE constants WP 1.5–2.4 + * reads). `$_pg_user` is left `null` there; callers that care about + * DB-level capability info fall back to `$GLOBALS['current_user']`. + */ +function legacyAuthCookieBlock(usernamePhpExpr: string): string { + return ` +$_pg_user = null; +if (function_exists('wp_generate_auth_cookie')) { + $_pg_user = function_exists('get_user_by') + ? get_user_by('login', ${usernamePhpExpr}) + : (function_exists('get_userdatabylogin') + ? get_userdatabylogin(${usernamePhpExpr}) : null); + if ($_pg_user) { + wp_set_current_user($_pg_user->ID, $_pg_user->user_login); + $_pg_exp = time() + 172800; + if (defined('AUTH_COOKIE')) + $_COOKIE[AUTH_COOKIE] = wp_generate_auth_cookie($_pg_user->ID, $_pg_exp, 'auth'); + if (defined('SECURE_AUTH_COOKIE')) + $_COOKIE[SECURE_AUTH_COOKIE] = wp_generate_auth_cookie($_pg_user->ID, $_pg_exp, 'secure_auth'); + if (defined('LOGGED_IN_COOKIE')) + $_COOKIE[LOGGED_IN_COOKIE] = wp_generate_auth_cookie($_pg_user->ID, $_pg_exp, 'logged_in'); + } +} +`; +} + +/** + * Wraps WP 2.1–3.2's top-level `$wp_queries = "CREATE TABLE …"` in a + * wp_get_db_schema() polyfill. Consumed by runDbDeltaOnly() in + * legacy-boot.ts; WP 3.3+ ships the function natively. + */ +async function patchWpSchemaPhp(php: PHP, documentRoot: string) { + const wpVersion = readOnDiskWpVersion(php, documentRoot); + if (wpVersion === null) return; + const parsed = parseFloat(wpVersion); + if (!Number.isFinite(parsed) || parsed >= 3.3) return; + + const schemaPhpPath = joinPaths( + documentRoot, + 'wp-admin/includes/schema.php' + ); + if (!php.fileExists(schemaPhpPath)) return; + + const schemaPhp = php.readFileAsText(schemaPhpPath); + if ( + /\$wp_queries\s*=\s*"CREATE TABLE/.test(schemaPhp) && + !schemaPhp.includes('function wp_get_db_schema') + ) { + await patchInlineSchemaPhp(php, documentRoot, schemaPhpPath, schemaPhp); + } +} + +/** + * Adds wp_get_db_schema() polyfill to WP < 3.3 schema.php. + * + * Also patches upgrade.php so make_db_current_silent() regenerates + * $wp_queries via wp_get_db_schema() before passing it to dbDelta(). + */ +async function patchInlineSchemaPhp( + php: PHP, + documentRoot: string, + schemaPhpPath: string, + schemaPhp: string +) { + const startMatch = schemaPhp.match(/\$wp_queries\s*=\s*"CREATE TABLE/); + if (!startMatch || startMatch.index === undefined) { + return; + } + const startIdx = startMatch.index; + + const endMarker = '";'; + const endIdx = schemaPhp.indexOf(endMarker, startIdx); + if (endIdx === -1) { + return; + } + const endPos = endIdx + endMarker.length; + + const wpQueriesBlock = schemaPhp.substring(startIdx, endPos); + + const replacement = + `function wp_get_db_schema( $scope = 'all', $blog_id = null ) {\n` + + `\tglobal $wpdb, $wp_queries, $charset_collate;\n` + + `\t$charset_collate = '';\n` + + `\tif ( ! empty($wpdb->charset) )\n` + + `\t\t$charset_collate = "DEFAULT CHARACTER SET $wpdb->charset";\n` + + `\tif ( ! empty($wpdb->collate) )\n` + + `\t\t$charset_collate .= " COLLATE $wpdb->collate";\n` + + `\t${wpQueriesBlock}\n` + + `\treturn $wp_queries;\n` + + `}`; + + const patched = + schemaPhp.substring(0, startIdx) + + replacement + + schemaPhp.substring(endPos); + await php.writeFile(schemaPhpPath, patched); + + const upgradePhpPath = joinPaths( + documentRoot, + 'wp-admin/includes/upgrade.php' + ); + if (php.fileExists(upgradePhpPath)) { + const upgradePhp = php.readFileAsText(upgradePhpPath); + + const dbDeltaReplacement = + `if ( function_exists('wp_get_db_schema') ) { ` + + `$wp_queries = wp_get_db_schema(); } ` + + `$1`; + const updated = upgradePhp.replace( + /(\$alterations\s*=\s*dbDelta\(\s*\$wp_queries\s*\))/g, + dbDeltaReplacement + ); + if (updated !== upgradePhp) { + await php.writeFile(upgradePhpPath, updated); + } + } +} +/** + * Returns the PHP content for wp-content/db.php. + * + * This db.php provides MySQL/MySQLi function stubs and, for WP < 3.0, + * loads the SQLite integration directly. Modern WP only needs this file + * to *exist* (to bypass the extension_loaded('mysql') check), but old + * WP actually uses the stubs defined here. + */ +export function generateDbPhpContent(): string { + // 0-sqlite.php preload runs first via auto_prepend_file and already + // defines mysql_*, mysqli_connect/init, and str_* polyfills. Only + // the mysqli_* stubs that the preload doesn't cover live here. + return `reinitialize_sqlite(); + } +} +// Remaining mysqli_* stubs not covered by the 0-sqlite.php preload. +// WP 4.x's extension_loaded('mysqli') check expects these to exist. +if (!function_exists('mysqli_real_connect')) { + function mysqli_real_connect() { return true; } +} +if (!function_exists('mysqli_error')) { + function mysqli_error() { return ''; } +} +if (!function_exists('mysqli_errno')) { + function mysqli_errno() { return 0; } +} +if (!function_exists('mysqli_query')) { + function mysqli_query() { return false; } +} +if (!function_exists('mysqli_set_charset')) { + function mysqli_set_charset() { return true; } +} +if (!function_exists('mysqli_select_db')) { + function mysqli_select_db() { return true; } +} +if (!function_exists('mysqli_close')) { + function mysqli_close() { return true; } +} +`; +} + +/** + * Post-install fixups for legacy WordPress. + * + * Stage 1 (always): boots WordPress and patches data via $wpdb — + * siteurl/home, admin password, roles/caps, default content. + * + * Stage 2 (WP < 3.5 only): direct PDO writes that create the WP 1.x-era + * schema and seed users/posts/categories/options. Runs in addition to + * stage 1 (idempotent guards), so it backfills whatever stage 1 missed + * — including the case where wp-load.php crashed before stage 1 ran. + * Skipped for WP 3.5+ to avoid polluting the AST driver's schema with + * legacy-shaped tables it never registers in information_schema. + */ +export async function runPostInstallLegacyFixups( + php: PHP, + siteUrl: string +): Promise { + let wpVersion: string | null = null; + const versionPhp = joinPaths(php.documentRoot, 'wp-includes/version.php'); + if (php.fileExists(versionPhp)) { + const m = php + .readFileAsText(versionPhp) + .match(/\$wp_version\s*=\s*['"]([^'"]+)['"]/); + if (m) wpVersion = m[1]; + } + const needsStage2 = wpVersion !== null && parseFloat(wpVersion) < 3.5; + try { + await php.run({ + code: `query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='wp_users'")->fetchColumn(); + $_pg_pdo = null; + if (!$_pg_check) { exit; } + $wp_load = getenv('DOCUMENT_ROOT') . '/wp-load.php'; + if (!file_exists($wp_load)) { exit; } + require $wp_load; + ob_clean(); + global $wpdb; + if (!isset($wpdb) || !method_exists($wpdb, 'query')) { exit; } + + // Persist the scoped siteurl/home to the DB so parse_request() + // strips the scope prefix from REQUEST_URI. Filters alone + // (env.php) aren't enough on WP < 2.2. + $_pg_opts = !empty($wpdb->options) ? $wpdb->options : $GLOBALS['table_prefix'] . 'options'; + try { + $_pg_url = getenv('PLAYGROUND_SITE_URL'); + if ($_pg_url) { + $_pg_current = $wpdb->get_var("SELECT option_value FROM {$_pg_opts} WHERE option_name = 'siteurl'"); + if ($_pg_current !== $_pg_url) { + $wpdb->query("UPDATE {$_pg_opts} SET option_value = '{$_pg_url}' WHERE option_name = 'siteurl'"); + $wpdb->query("UPDATE {$_pg_opts} SET option_value = '{$_pg_url}' WHERE option_name = 'home'"); + } + } + } catch (Exception $e) {} + + // $wpdb->users exists on WP 1.5+; older WP needs the prefix. + $users_table = !empty($wpdb->users) ? $wpdb->users : $GLOBALS['table_prefix'] . 'users'; + + // WP 1.0/1.2 installers often leave the users table or admin row missing. + $wpdb->query("CREATE TABLE IF NOT EXISTS {$users_table} ( + ID int(10) unsigned NOT NULL auto_increment, + user_login varchar(20) NOT NULL default '', + user_pass varchar(64) NOT NULL default '', + user_firstname varchar(50) NOT NULL default '', + user_lastname varchar(50) NOT NULL default '', + user_nickname varchar(50) NOT NULL default '', + user_icq int(10) unsigned NOT NULL default '0', + user_email varchar(100) NOT NULL default '', + user_url varchar(100) NOT NULL default '', + user_ip varchar(15) NOT NULL default '', + user_domain varchar(200) NOT NULL default '', + user_browser varchar(200) NOT NULL default '', + dateYMDhour datetime NOT NULL default '0000-00-00 00:00:00', + user_level int(2) unsigned NOT NULL default '0', + user_aim varchar(50) NOT NULL default '', + user_msn varchar(100) NOT NULL default '', + user_yim varchar(50) NOT NULL default '', + user_idmode varchar(20) NOT NULL default '', + PRIMARY KEY (ID), + UNIQUE KEY user_login (user_login) + )"); + if (!$wpdb->get_var("SELECT COUNT(*) FROM {$users_table}")) { + $now = date('Y-m-d H:i:s'); + $wpdb->query( + "INSERT INTO {$users_table} (ID, user_login, user_pass, user_email, user_level, dateYMDhour, user_nickname) " . + "VALUES (1, 'admin', MD5('password'), 'admin@localhost.com', 10, '{$now}', 'admin')" + ); + } + $wpdb->query( + "UPDATE {$users_table} SET user_pass = MD5('password') WHERE user_login = 'admin'" + ); + + // populate_roles() can fail on SQLite; seed the admin role and caps directly. + $p = $GLOBALS['table_prefix']; + $roles_key = $p . 'user_roles'; + try { + $has_roles = $wpdb->get_var( + "SELECT COUNT(*) FROM {$p}options WHERE option_name = '{$roles_key}'" + ); + } catch (Exception $e) { + $has_roles = 0; + } + if (!$has_roles) { + $roles = array('administrator' => array( + 'name' => 'Administrator', + 'capabilities' => array( + 'switch_themes'=>true, 'edit_themes'=>true, + 'activate_plugins'=>true, 'edit_plugins'=>true, + 'edit_users'=>true, 'edit_files'=>true, + 'manage_options'=>true, 'moderate_comments'=>true, + 'manage_categories'=>true, 'manage_links'=>true, + 'upload_files'=>true, 'import'=>true, + 'unfiltered_html'=>true, 'edit_posts'=>true, + 'edit_others_posts'=>true, 'edit_published_posts'=>true, + 'publish_posts'=>true, 'edit_pages'=>true, + 'read'=>true, 'level_10'=>true, 'level_9'=>true, + 'level_8'=>true, 'level_7'=>true, 'level_6'=>true, + 'level_5'=>true, 'level_4'=>true, 'level_3'=>true, + 'level_2'=>true, 'level_1'=>true, 'level_0'=>true, + 'edit_others_pages'=>true, 'edit_published_pages'=>true, + 'publish_pages'=>true, 'delete_pages'=>true, + 'delete_others_pages'=>true, 'delete_published_pages'=>true, + 'delete_posts'=>true, 'delete_others_posts'=>true, + 'delete_published_posts'=>true, 'delete_private_posts'=>true, + 'edit_private_posts'=>true, 'read_private_posts'=>true, + 'delete_private_pages'=>true, 'edit_private_pages'=>true, + 'read_private_pages'=>true, + ) + )); + $wpdb->query("INSERT INTO {$p}options (option_name, option_value, autoload) VALUES ('{$roles_key}', '" . addslashes(serialize($roles)) . "', 'yes')"); + } + $um = isset($wpdb->usermeta) ? $wpdb->usermeta : $p . 'usermeta'; + try { + $has_cap = $wpdb->get_var("SELECT COUNT(*) FROM {$um} WHERE user_id=1 AND meta_key='{$p}capabilities'"); + if (!$has_cap) { + $cap_val = addslashes(serialize(array('administrator' => true))); + $wpdb->query("INSERT INTO {$um} (user_id, meta_key, meta_value) VALUES (1, '{$p}capabilities', '{$cap_val}')"); + } + $has_level = $wpdb->get_var("SELECT COUNT(*) FROM {$um} WHERE user_id=1 AND meta_key='{$p}user_level'"); + if (!$has_level) { + $wpdb->query("INSERT INTO {$um} (user_id, meta_key, meta_value) VALUES (1, '{$p}user_level', '10')"); + } + } catch (Exception $e) {} + + // Seed default content when the install left the posts table empty. + $posts_table = !empty($wpdb->posts) ? $wpdb->posts : $GLOBALS['table_prefix'] . 'posts'; + $has_posts = false; + try { $has_posts = (bool)$wpdb->get_var("SELECT COUNT(*) FROM {$posts_table}"); } catch (Exception $e) {} + if (!$has_posts) { + $now = date('Y-m-d H:i:s'); + $now_gmt = gmdate('Y-m-d H:i:s'); + if (isset($wpdb->categories)) { + $wpdb->query("INSERT INTO {$wpdb->categories} (cat_ID, cat_name, category_nicename, category_description, category_parent) VALUES (1, 'Uncategorized', 'uncategorized', '', 0)"); + } + // Columns common to WP 1.0+. + $wpdb->query("INSERT INTO {$posts_table} (ID, post_author, post_date, post_date_gmt, post_content, post_title, post_excerpt, post_status, comment_status, ping_status, post_password, post_name, to_ping, pinged, post_modified, post_modified_gmt, post_content_filtered) VALUES (1, 1, '{$now}', '{$now_gmt}', 'Welcome to WordPress. This is your first post. Edit or delete it, then start blogging!', 'Hello world!', '', 'publish', 'open', 'open', '', 'hello-world', '', '', '{$now}', '{$now_gmt}', '')"); + if (isset($wpdb->comments)) { + $wpdb->query("INSERT INTO {$wpdb->comments} (comment_post_ID, comment_author, comment_author_email, comment_author_url, comment_author_IP, comment_date, comment_date_gmt, comment_content, comment_karma, comment_approved, comment_agent, comment_type, comment_parent, user_id) VALUES (1, 'Mr WordPress', '', 'http://wordpress.org', '127.0.0.1', '{$now}', '{$now_gmt}', 'Hi, this is a comment. To delete a comment, just log in and view the post comments. There you will have the option to edit or delete them.', 0, '1', '', '', 0, 0)"); + } + if (isset($wpdb->post2cat)) { + $wpdb->query("INSERT INTO {$wpdb->post2cat} (rel_id, post_id, category_id) VALUES (1, 1, 1)"); + } + } + `, + env: { + DOCUMENT_ROOT: php.documentRoot, + PLAYGROUND_SITE_URL: siteUrl || '', + }, + }); + } catch (error) { + logger.warn('Legacy WP post-install fixups failed (non-fatal):', error); + } + + if (!needsStage2) return; + try { + await php.run({ + code: `setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + $prefix = 'wp_'; + $table = $prefix . 'users'; + try { + $count = $pdo->query("SELECT COUNT(*) FROM {$table}")->fetchColumn(); + } catch (Exception $e) { + $pdo->exec("CREATE TABLE IF NOT EXISTS {$table} ( + ID INTEGER PRIMARY KEY AUTOINCREMENT, + user_login TEXT NOT NULL DEFAULT '', + user_pass TEXT NOT NULL DEFAULT '', + user_nickname TEXT NOT NULL DEFAULT '', + user_email TEXT NOT NULL DEFAULT '', + user_url TEXT NOT NULL DEFAULT '', + user_ip TEXT NOT NULL DEFAULT '', + user_domain TEXT NOT NULL DEFAULT '', + user_browser TEXT NOT NULL DEFAULT '', + dateYMDhour TEXT NOT NULL DEFAULT '0000-00-00 00:00:00', + user_level INTEGER NOT NULL DEFAULT 0, + user_idmode TEXT NOT NULL DEFAULT '', + user_firstname TEXT NOT NULL DEFAULT '', + user_lastname TEXT NOT NULL DEFAULT '', + user_icq INTEGER NOT NULL DEFAULT 0, + user_aim TEXT NOT NULL DEFAULT '', + user_msn TEXT NOT NULL DEFAULT '', + user_yim TEXT NOT NULL DEFAULT '' + )"); + $count = 0; + } + if ($count == 0) { + $now = date('Y-m-d H:i:s'); + // SECURITY: md5('password') matches WP 1.0-1.2's single-md5 + // scheme so auto-login works without a blueprint password. + // Safe only inside the Playground WASM sandbox. + $pass = md5('password'); + try { + $col_info = $pdo->query("PRAGMA table_info({$table})")->fetchAll(PDO::FETCH_ASSOC); + $known = array( + 'ID' => '1', 'user_login' => "'admin'", + 'user_pass' => "'{$pass}'", 'user_email' => "'admin@localhost.com'", + 'user_level' => '10', 'dateYMDhour' => "'{$now}'", + 'user_nickname' => "'admin'", 'user_nicename' => "'admin'", + 'user_registered' => "'{$now}'", 'user_status' => '0', + ); + $ins_cols = array(); $ins_vals = array(); + foreach ($col_info as $ci) { + $cn = $ci['name']; + $ins_cols[] = $cn; + if (isset($known[$cn])) { + $ins_vals[] = $known[$cn]; + } elseif ($ci['dflt_value'] !== null) { + $ins_vals[] = $ci['dflt_value']; + } elseif (stripos($ci['type'], 'int') !== false) { + $ins_vals[] = '0'; + } else { + $ins_vals[] = "''"; + } + } + $pdo->exec("INSERT INTO {$table} (" . implode(',', $ins_cols) . ") VALUES (" . implode(',', $ins_vals) . ")"); + } catch (Exception $e) {} + } else { + // See SECURITY note above. + $pass = md5('password'); + try { $pdo->exec("UPDATE {$table} SET user_pass = '{$pass}' WHERE user_login = 'admin'"); } catch (Exception $e) {} + } + + // WP 1.0-1.2 install often leaves these tables missing because + // the SQLite driver can't translate the old-style CREATE TABLEs. + $now = date('Y-m-d H:i:s'); + $now_gmt = gmdate('Y-m-d H:i:s'); + $tables_sql = array( + 'posts' => "CREATE TABLE IF NOT EXISTS {$prefix}posts ( + ID INTEGER PRIMARY KEY AUTOINCREMENT, + post_author INTEGER NOT NULL DEFAULT 0, + post_date TEXT NOT NULL DEFAULT '0000-00-00 00:00:00', + post_date_gmt TEXT NOT NULL DEFAULT '0000-00-00 00:00:00', + post_content TEXT NOT NULL DEFAULT '', + post_title TEXT NOT NULL DEFAULT '', + post_category INTEGER NOT NULL DEFAULT 0, + post_excerpt TEXT NOT NULL DEFAULT '', + post_status TEXT NOT NULL DEFAULT 'publish', + comment_status TEXT NOT NULL DEFAULT 'open', + ping_status TEXT NOT NULL DEFAULT 'open', + post_password TEXT NOT NULL DEFAULT '', + post_name TEXT NOT NULL DEFAULT '', + to_ping TEXT NOT NULL DEFAULT '', + pinged TEXT NOT NULL DEFAULT '', + post_modified TEXT NOT NULL DEFAULT '0000-00-00 00:00:00', + post_modified_gmt TEXT NOT NULL DEFAULT '0000-00-00 00:00:00', + post_content_filtered TEXT NOT NULL DEFAULT '', + post_parent INTEGER NOT NULL DEFAULT 0, + menu_order INTEGER NOT NULL DEFAULT 0, + post_mime_type TEXT NOT NULL DEFAULT '' + )", + 'categories' => "CREATE TABLE IF NOT EXISTS {$prefix}categories ( + cat_ID INTEGER PRIMARY KEY AUTOINCREMENT, + cat_name TEXT NOT NULL DEFAULT '', + category_nicename TEXT NOT NULL DEFAULT '', + category_description TEXT NOT NULL DEFAULT '', + category_parent INTEGER NOT NULL DEFAULT 0 + )", + 'post2cat' => "CREATE TABLE IF NOT EXISTS {$prefix}post2cat ( + rel_id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id INTEGER NOT NULL DEFAULT 0, + category_id INTEGER NOT NULL DEFAULT 0 + )", + 'comments' => "CREATE TABLE IF NOT EXISTS {$prefix}comments ( + comment_ID INTEGER PRIMARY KEY AUTOINCREMENT, + comment_post_ID INTEGER NOT NULL DEFAULT 0, + comment_author TEXT NOT NULL DEFAULT '', + comment_author_email TEXT NOT NULL DEFAULT '', + comment_author_url TEXT NOT NULL DEFAULT '', + comment_author_IP TEXT NOT NULL DEFAULT '', + comment_date TEXT NOT NULL DEFAULT '0000-00-00 00:00:00', + comment_date_gmt TEXT NOT NULL DEFAULT '0000-00-00 00:00:00', + comment_content TEXT NOT NULL DEFAULT '', + comment_karma INTEGER NOT NULL DEFAULT 0, + comment_approved TEXT NOT NULL DEFAULT '1', + comment_agent TEXT NOT NULL DEFAULT '', + comment_type TEXT NOT NULL DEFAULT '', + comment_parent INTEGER NOT NULL DEFAULT 0, + user_id INTEGER NOT NULL DEFAULT 0 + )", + 'options' => "CREATE TABLE IF NOT EXISTS {$prefix}options ( + option_id INTEGER PRIMARY KEY AUTOINCREMENT, + blog_id INTEGER NOT NULL DEFAULT 0, + option_name TEXT NOT NULL DEFAULT '', + option_can_override TEXT NOT NULL DEFAULT 'Y', + option_type INTEGER NOT NULL DEFAULT 1, + option_value TEXT NOT NULL DEFAULT '', + option_width INTEGER NOT NULL DEFAULT 20, + option_height INTEGER NOT NULL DEFAULT 8, + option_description TEXT NOT NULL DEFAULT '', + option_admin_level INTEGER NOT NULL DEFAULT 1, + autoload TEXT NOT NULL DEFAULT 'yes' + )", + 'postmeta' => "CREATE TABLE IF NOT EXISTS {$prefix}postmeta ( + meta_id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id INTEGER NOT NULL DEFAULT 0, + meta_key TEXT NOT NULL DEFAULT '', + meta_value TEXT NOT NULL DEFAULT '' + )", + 'links' => "CREATE TABLE IF NOT EXISTS {$prefix}links ( + link_id INTEGER PRIMARY KEY AUTOINCREMENT, + link_url TEXT NOT NULL DEFAULT '', + link_name TEXT NOT NULL DEFAULT '', + link_image TEXT NOT NULL DEFAULT '', + link_target TEXT NOT NULL DEFAULT '', + link_category INTEGER NOT NULL DEFAULT 0, + link_description TEXT NOT NULL DEFAULT '', + link_visible TEXT NOT NULL DEFAULT 'Y', + link_owner INTEGER NOT NULL DEFAULT 1, + link_rating INTEGER NOT NULL DEFAULT 0, + link_updated TEXT NOT NULL DEFAULT '0000-00-00 00:00:00', + link_rel TEXT NOT NULL DEFAULT '', + link_notes TEXT NOT NULL DEFAULT '', + link_rss TEXT NOT NULL DEFAULT '' + )", + 'linkcategories' => "CREATE TABLE IF NOT EXISTS {$prefix}linkcategories ( + cat_id INTEGER PRIMARY KEY AUTOINCREMENT, + cat_name TEXT NOT NULL DEFAULT '', + auto_toggle TEXT NOT NULL DEFAULT 'N', + show_images TEXT NOT NULL DEFAULT 'Y', + show_description TEXT NOT NULL DEFAULT 'N', + show_rating TEXT NOT NULL DEFAULT 'Y', + show_updated TEXT NOT NULL DEFAULT 'Y', + sort_order TEXT NOT NULL DEFAULT 'name', + sort_desc TEXT NOT NULL DEFAULT 'ASC', + text_before_link TEXT NOT NULL DEFAULT '
  • ', + text_after_link TEXT NOT NULL DEFAULT '
    ', + text_after_all TEXT NOT NULL DEFAULT '
  • ', + list_limit INTEGER NOT NULL DEFAULT -1 + )", + 'optiongroups' => "CREATE TABLE IF NOT EXISTS {$prefix}optiongroups ( + group_id INTEGER PRIMARY KEY AUTOINCREMENT, + group_name TEXT NOT NULL DEFAULT '', + group_desc TEXT DEFAULT '', + group_longdesc TEXT DEFAULT '' + )", + 'optiongroup_options' => "CREATE TABLE IF NOT EXISTS {$prefix}optiongroup_options ( + group_id INTEGER NOT NULL DEFAULT 0, + option_id INTEGER NOT NULL DEFAULT 0, + seq INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (group_id, option_id) + )" + ); + foreach ($tables_sql as $t => $sql) { + try { $pdo->exec($sql); } catch (Exception $e) {} + } + // Backfill columns that WP 1.0-1.2 installs leave off but later code paths read. + $alter_cols = array( + 'categories' => array( + 'category_nicename' => "TEXT NOT NULL DEFAULT ''", + 'category_description' => "TEXT NOT NULL DEFAULT ''", + 'category_parent' => "INTEGER NOT NULL DEFAULT 0", + 'category_count' => "INTEGER NOT NULL DEFAULT 0", + ), + // WP 1.5+ get_comments_number() reads comment_count off wp_posts. + 'posts' => array( + 'comment_count' => "INTEGER NOT NULL DEFAULT 0", + ), + ); + foreach ($alter_cols as $t => $cols_to_add) { + try { + $existing = $pdo->query("PRAGMA table_info({$prefix}{$t})")->fetchAll(PDO::FETCH_COLUMN, 1); + foreach ($cols_to_add as $col => $type) { + if (!in_array($col, $existing)) { + $pdo->exec("ALTER TABLE {$prefix}{$t} ADD COLUMN {$col} {$type}"); + } + } + } catch (Exception $e) {} + } + // Dynamic column detection because the schema differs across WP 1.x. + try { + if (!$pdo->query("SELECT COUNT(*) FROM {$prefix}posts")->fetchColumn()) { + $post_cols = $pdo->query("PRAGMA table_info({$prefix}posts)")->fetchAll(PDO::FETCH_COLUMN, 1); + $post_vals = array( + 'ID' => '1', 'post_author' => '1', + 'post_date' => "'{$now}'", 'post_date_gmt' => "'{$now_gmt}'", + 'post_content' => "'Welcome to WordPress. This is your first post. Edit or delete it, then start blogging!'", + 'post_title' => "'Hello world!'", 'post_excerpt' => "''", + 'post_status' => "'publish'", 'comment_status' => "'open'", + 'ping_status' => "'open'", 'post_password' => "''", + 'post_name' => "'hello-world'", 'to_ping' => "''", 'pinged' => "''", + 'post_modified' => "'{$now}'", 'post_modified_gmt' => "'{$now_gmt}'", + 'post_content_filtered' => "''", + ); + $ins_c = array(); $ins_v = array(); + foreach ($post_vals as $c => $v) { + if (in_array($c, $post_cols)) { $ins_c[] = $c; $ins_v[] = $v; } + } + if ($ins_c) $pdo->exec("INSERT INTO {$prefix}posts (" . implode(',', $ins_c) . ") VALUES (" . implode(',', $ins_v) . ")"); + } + } catch (Exception $e) {} + try { + if (!$pdo->query("SELECT COUNT(*) FROM {$prefix}categories")->fetchColumn()) { + $pdo->exec("INSERT INTO {$prefix}categories (cat_ID, cat_name, category_nicename, category_description, category_parent) VALUES (1, 'Uncategorized', 'uncategorized', '', 0)"); + } + } catch (Exception $e) {} + try { + $env_site = getenv('PLAYGROUND_SITE_URL'); + $site = $env_site ? $env_site : 'http://localhost'; + if (!$pdo->query("SELECT COUNT(*) FROM {$prefix}options WHERE option_name='siteurl'")->fetchColumn()) { + $pdo->exec("INSERT INTO {$prefix}options (option_name, option_value) VALUES ('siteurl', '{$site}')"); + $pdo->exec("INSERT INTO {$prefix}options (option_name, option_value) VALUES ('blogname', 'My WordPress Website')"); + $pdo->exec("INSERT INTO {$prefix}options (option_name, option_value) VALUES ('blogdescription', 'Just another WordPress weblog')"); + $pdo->exec("INSERT INTO {$prefix}options (option_name, option_value) VALUES ('home', '{$site}')"); + } + // Overwrite the placeholder 'http://localhost' with the scoped URL. + if ($env_site) { + $pdo->exec("UPDATE {$prefix}options SET option_value = '{$env_site}' WHERE option_name = 'siteurl'"); + $pdo->exec("UPDATE {$prefix}options SET option_value = '{$env_site}' WHERE option_name = 'home'"); + } + // populate_options() sets template/stylesheet; backfill if it crashed. + if (!$pdo->query("SELECT COUNT(*) FROM {$prefix}options WHERE option_name='template'")->fetchColumn()) { + $themes_dir = getenv('DOCUMENT_ROOT') . '/wp-content/themes/'; + $tpl = 'default'; + if (is_dir($themes_dir)) { + $entries = glob($themes_dir . '*', GLOB_ONLYDIR); + if ($entries) { + foreach ($entries as $e) { + $name = basename($e); + if ($name === '.' || $name === '..') continue; + if (file_exists($e . '/style.css')) { + $tpl = $name; + break; + } + } + } + } + $pdo->exec("INSERT INTO {$prefix}options (option_name, option_value, autoload) VALUES ('template', '{$tpl}', 'yes')"); + $pdo->exec("INSERT INTO {$prefix}options (option_name, option_value, autoload) VALUES ('stylesheet', '{$tpl}', 'yes')"); + } + // Without a correct db_version, WP 2.0-2.5 admin redirects to upgrade.php. + $version_path = getenv('DOCUMENT_ROOT') . '/wp-includes/version.php'; + if (file_exists($version_path)) { + $wp_db_version = 0; + include $version_path; + if ($wp_db_version > 0) { + $has_dbv = $pdo->query("SELECT COUNT(*) FROM {$prefix}options WHERE option_name='db_version'")->fetchColumn(); + if (!$has_dbv) { + $pdo->exec("INSERT INTO {$prefix}options (option_name, option_value, autoload) VALUES ('db_version', '{$wp_db_version}', 'yes')"); + } else { + $pdo->exec("UPDATE {$prefix}options SET option_value = '{$wp_db_version}' WHERE option_name = 'db_version'"); + } + } + } + } catch (Exception $e) {} + `, + env: { + DOCUMENT_ROOT: php.documentRoot, + PLAYGROUND_SITE_URL: siteUrl || '', + }, + }); + } catch (error) { + logger.warn('Legacy WP PDO fallback failed (non-fatal):', error); + } +} diff --git a/packages/playground/wordpress/src/legacy-wp/legacy-mu-plugins.ts b/packages/playground/wordpress/src/legacy-wp/legacy-mu-plugins.ts new file mode 100644 index 00000000000..6cb2a43766f --- /dev/null +++ b/packages/playground/wordpress/src/legacy-wp/legacy-mu-plugins.ts @@ -0,0 +1,501 @@ +/** + * Platform-level mu-plugin setup for legacy PHP (< 7) running old + * WordPress (1.0–2.8). Self-contained mirror of + * {@link setupPlatformLevelMuPlugins} in index.ts — the modern + * function dispatches here when isLegacyPHPVersion(phpVersion) is + * true, and this file owns every PHP string that differs from the + * modern path. + * + * The three common mu-plugins (0-playground.php, sitemap-redirect, + * inline-tinymce-content-css) are shared with the modern path via + * {@link writeCommonPlatformMuPlugins} to avoid duplicating ~200 + * lines of identical PHP. + */ +import type { UniversalPHP } from '@php-wasm/universal'; +import { writeCommonPlatformMuPlugins } from '../platform-mu-plugins'; + +/** + * Auto-login body for legacy WordPress, dispatching to the cookie/auth + * API actually present at runtime: + * WP 2.5+ — wp_set_current_user() + wp_set_auth_cookie() (HMAC). + * WP 1.5–2.4 — USER_COOKIE/PASS_COOKIE constants + double-md5. + * WP 1.0–1.2 — wordpressuser_/wordpresspass_ cookies + globals. + */ +const LEGACY_AUTO_LOGIN_BODY = ` + if (function_exists('is_user_logged_in') && is_user_logged_in()) { + return; + } + if (headers_sent()) { + return; + } + + // Legacy auto-login never redirects; it populates $_COOKIE + // in-process for the current request and relies on + // setcookie() / wp_set_auth_cookie() to persist across + // requests via HttpCookieStore. + + // WP 2.5+ + if (function_exists('wp_set_current_user') && function_exists('wp_set_auth_cookie')) { + $user = function_exists('get_user_by') + ? get_user_by('login', $user_name) + : (function_exists('get_userdatabylogin') + ? get_userdatabylogin($user_name) : null); + if (!$user) return; + + wp_set_current_user($user->ID, $user->user_login); + // Populate $_COOKIE in-process so auth_redirect() and + // wp_verify_nonce() see the session for the remainder + // of this request; wp_set_auth_cookie() also emits + // Set-Cookie for subsequent requests. + wp_set_auth_cookie($user->ID); + if (function_exists('wp_generate_auth_cookie')) { + $_pg_exp = time() + 172800; + if (defined('AUTH_COOKIE')) + $_COOKIE[AUTH_COOKIE] = wp_generate_auth_cookie($user->ID, $_pg_exp, 'auth'); + if (defined('SECURE_AUTH_COOKIE')) + $_COOKIE[SECURE_AUTH_COOKIE] = wp_generate_auth_cookie($user->ID, $_pg_exp, 'secure_auth'); + if (defined('LOGGED_IN_COOKIE')) + $_COOKIE[LOGGED_IN_COOKIE] = wp_generate_auth_cookie($user->ID, $_pg_exp, 'logged_in'); + } + return; + } + + // WP 1.5–2.4 + if (defined('USER_COOKIE') && defined('PASS_COOKIE')) { + $_pg_pass_cookie = md5(md5('password')); + $_COOKIE[USER_COOKIE] = $user_name; + $_COOKIE[PASS_COOKIE] = $_pg_pass_cookie; + if (!headers_sent()) { + $_pg_exp = time() + 172800; + setcookie(USER_COOKIE, $user_name, $_pg_exp, '/'); + setcookie(PASS_COOKIE, $_pg_pass_cookie, $_pg_exp, '/'); + } + $GLOBALS['current_user'] = null; + if (function_exists('get_currentuserinfo')) { + get_currentuserinfo(); + } + return; + } + + // WP 1.0–1.2: cookies are usually already set by + // playground_legacy_set_auth_cookies_early() in env.php, + // but WP 1.0–1.2 reads its user state from globals (no + // WP_User), so populate those explicitly here. + $cookiehash = defined('COOKIEHASH') + ? COOKIEHASH + : (isset($GLOBALS['cookiehash']) && $GLOBALS['cookiehash'] + ? $GLOBALS['cookiehash'] + : (function_exists('get_settings') + ? md5(get_settings('siteurl')) + : '')); + if ($cookiehash) { + $_pg_user_cookie_name = 'wordpressuser_' . $cookiehash; + $_pg_pass_cookie_name = 'wordpresspass_' . $cookiehash; + $_pg_pass_cookie_value = md5(md5('password')); + $_COOKIE[$_pg_user_cookie_name] = $user_name; + $_COOKIE[$_pg_pass_cookie_name] = $_pg_pass_cookie_value; + if (!headers_sent()) { + $_pg_exp = time() + 172800; + setcookie($_pg_user_cookie_name, $user_name, $_pg_exp, '/'); + setcookie($_pg_pass_cookie_name, $_pg_pass_cookie_value, $_pg_exp, '/'); + } + if (function_exists('get_userdatabylogin')) { + $userdata = get_userdatabylogin($user_name); + if ($userdata) { + $GLOBALS['user_login'] = $user_name; + $GLOBALS['userdata'] = $userdata; + $GLOBALS['user_level'] = isset($userdata->user_level) ? (int) $userdata->user_level : 10; + $GLOBALS['user_ID'] = $userdata->ID; + $GLOBALS['user_email'] = isset($userdata->user_email) ? $userdata->user_email : ''; + $GLOBALS['user_url'] = isset($userdata->user_url) ? $userdata->user_url : ''; + $GLOBALS['user_nickname'] = isset($userdata->user_nickname) ? $userdata->user_nickname : $user_name; + $GLOBALS['user_pass_md5'] = md5(isset($userdata->user_pass) ? $userdata->user_pass : ''); + } + } + return; + } +`; + +/** + * Full legacy version of {@link setupPlatformLevelMuPlugins}. Writes + * a custom auto_prepend_file, legacy-aware preload env.php, legacy + * auto-login mu-plugin, the common platform mu-plugins, and the PHP + * 5.2 variant of the error handler. + */ +export async function setupLegacyPlatformLevelMuPlugins( + php: UniversalPHP +): Promise { + await php.mkdir('/internal/shared/mu-plugins'); + + // Overwrite auto_prepend_file.php to add PHP 4 superglobal + // polyfills that WP 1.0-2.5 needs. The default + // auto_prepend_file only loads consts and preload files; + // legacy PHP also needs the superglobals set up first. + await php.writeFile( + '/internal/shared/auto_prepend_file.php', + ` $value) { + if (!defined($const) && is_scalar($value)) { + define($const, $value); + } + } + } +} +foreach (glob('/internal/shared/preload/*.php') as $file) { + require_once $file; +} +// Buffer early output so a stray PHP notice doesn't commit the +// response headers before the auto-login mu-plugin gets a chance +// to call wp_set_auth_cookie() / setcookie() on the init hook — +// otherwise nonce validation breaks on POST requests. PHP flushes +// the buffer at script end so output still reaches the browser. +ob_start(); +` + ); + + await php.writeFile( + '/internal/shared/preload/env.php', + `=')) { + $format = 'wp15'; + } elseif (version_compare($wp_version, '1.2', '>=')) { + $format = 'wp12'; + } else { + $format = 'wp10'; + } + return $format; +} + +// Adds filters/actions before WordPress is loaded by writing the +// $wp_filter shape the target version expects. $function_to_add +// MUST be a string (no closures). +function playground_add_filter( $tag, $function_to_add, $priority = 10, $accepted_args = 1 ) { + global $wp_filter; + $fmt = _playground_detect_wp_hook_format(); + if ($fmt === 'wp10') { + $wp_filter[$tag][] = $function_to_add; + } elseif ($fmt === 'wp12') { + $wp_filter[$tag][$priority][] = $function_to_add; + } else { + $wp_filter[$tag][$priority][$function_to_add] = array( + 'function' => $function_to_add, + 'accepted_args' => $accepted_args + ); + } +} +function playground_add_action( $tag, $function_to_add, $priority = 10, $accepted_args = 1 ) { + playground_add_filter( $tag, $function_to_add, $priority, $accepted_args ); +} + +// Set WP 1.0–2.4 auth cookies before WordPress loads — by the time +// the init hook fires (and on WP 1.0–1.2 it may not fire at all on +// the front page) WordPress has already read $_COOKIE. setcookie() +// also persists them across requests via HttpCookieStore. +// WP 2.5+ uses the HMAC auth cookie scheme and doesn't read these +// wordpressuser_/wordpresspass_ cookies at all — bail there so we +// don't write inert cookies the runtime would have to clean up. +function playground_legacy_set_auth_cookies_early() { + if (!defined('PLAYGROUND_AUTO_LOGIN_AS_USER')) return; + if (isset($_COOKIE['playground_auto_login_already_logged_out'])) return; + if (version_compare(_playground_detect_wp_version(), '2.5', '>=')) return; + + foreach ($_COOKIE as $name => $_) { + if (strncmp($name, 'wordpressuser_', 14) === 0) return; + } + + $user_name = PLAYGROUND_AUTO_LOGIN_AS_USER; + $pass_md5 = md5(md5('password')); + + // Read siteurl from SQLite so the cookie hash matches what + // WP 1.0–2.4 derives from get_settings('siteurl'). + $siteurl = null; + $db_path = defined('DB_DIR') ? DB_DIR . '.ht.sqlite' : ''; + if ($db_path && class_exists('PDO') && file_exists($db_path)) { + try { + $pdo = new PDO('sqlite:' . $db_path); + $stmt = $pdo->query("SELECT option_value FROM wp_options WHERE option_name = 'siteurl' LIMIT 1"); + if ($stmt) $siteurl = $stmt->fetchColumn(); + $pdo = null; + } catch (Exception $e) {} + } + if (!$siteurl && defined('WP_SITEURL')) $siteurl = WP_SITEURL; + if (!$siteurl) return; + + $cookiehash = md5($siteurl); + $user_cookie_name = 'wordpressuser_' . $cookiehash; + $pass_cookie_name = 'wordpresspass_' . $cookiehash; + $_COOKIE[$user_cookie_name] = $user_name; + $_COOKIE[$pass_cookie_name] = $pass_md5; + + if (!headers_sent()) { + $exp = time() + 172800; + setcookie($user_cookie_name, $user_name, $exp, '/'); + setcookie($pass_cookie_name, $pass_md5, $exp, '/'); + } +} +playground_legacy_set_auth_cookies_early(); + +// WP < 4.0 emits YEAR(post_date)='2026' AND MONTH(post_date)='4' +// against MySQL's loose type coercion. The SQLite driver's UDFs +// return integers and SQLite is strictly typed (4 != '4'), so +// strip quotes around numeric RHS values to keep both sides ints. +function playground_fix_sqlite_date_comparisons($query) { + if ( + stripos($query, 'YEAR') === false && + stripos($query, 'MONTH') === false && + stripos($query, 'DAY') === false + ) { + return $query; + } + return preg_replace( + '/\\b(YEAR|MONTH|DAYOFMONTH|DAY)\\s*\\(([^)]+)\\)\\s*=\\s*\\'(\\d+)\\'/i', + '$1($2) = $3', + $query + ); +} +playground_add_filter( 'query', 'playground_fix_sqlite_date_comparisons' ); + +// WP 2.2+ checks WP_SITEURL/WP_HOME inside get_option(); WP <2.2 +// doesn't, so backfill the same behaviour via the option filters +// to keep admin links on the Playground-scoped URL. +function playground_override_siteurl($value) { + if (defined('WP_SITEURL')) { + return WP_SITEURL; + } + return $value; +} +function playground_override_home($value) { + if (defined('WP_HOME')) { + return WP_HOME; + } + return $value; +} +playground_add_filter( 'option_siteurl', 'playground_override_siteurl' ); +playground_add_filter( 'option_home', 'playground_override_home' ); + +// Load mu-plugins last so customer mu-plugins win — and so they +// can't depend on muplugins_loaded. WP < 2.8 doesn't fire that +// action at all, so init -1000 acts as a fallback (the $loaded +// flag keeps it idempotent). +playground_add_action( 'muplugins_loaded', 'playground_load_mu_plugins', 0 ); +playground_add_action( 'init', 'playground_load_mu_plugins', -1000 ); +function playground_load_mu_plugins() { + static $loaded = false; + if ($loaded) return; + $loaded = true; + $mu_plugins_dir = '/internal/shared/mu-plugins'; + if(!is_dir($mu_plugins_dir)){ + return; + } + $mu_plugins = glob( $mu_plugins_dir . '/*.php' ); + sort( $mu_plugins ); + global $wp_version; + $is_legacy_wp = isset($wp_version) && version_compare($wp_version, '2.8', '<'); + foreach ( $mu_plugins as $mu_plugin ) { + // Loaded separately by the preload lazy loader or db.php. + if (strpos($mu_plugin, 'sqlite-database-integration') !== false) { + continue; + } + // WP < 2.8 crashes on closures in hooks and lacks + // site_url() (added 2.6). 1-auto-login.php is written + // without either, so it's the only mu-plugin we load + // on legacy WP. + if ($is_legacy_wp) { + if (strpos($mu_plugin, '1-auto-login.php') === false) { + continue; + } + } + require_once $mu_plugin; + } + + // PHP 5.x's foreach over $wp_filter['init'] iterates a copy, + // so add_action() calls made by the mu-plugin we just loaded + // won't fire on this same init run. Call them directly. + if ($is_legacy_wp) { + if (function_exists('playground_auto_login_redirect_target')) { + playground_auto_login_redirect_target(); + } + if (function_exists('playground_auto_login')) { + playground_auto_login(); + } + } +} +` + ); + + /** + * Automatically logs the user in to aid the login Blueprint step and + * the Playground runtimes. See the modern counterpart in + * index.ts for the shared doc. + */ + await php.writeFile( + '/internal/shared/mu-plugins/1-auto-login.php', + ` { + if (await php.isDir('/tmp/sqlite-database-integration')) { + await php.rmdir('/tmp/sqlite-database-integration', { + recursive: true, + }); + } + await php.mkdir('/tmp/sqlite-database-integration'); + await unzipFile(php, sqliteZip, '/tmp/sqlite-database-integration'); + const SQLITE_PLUGIN_FOLDER = '/internal/shared/sqlite-database-integration'; + + // The SQLite integration plugin was extracted into the sole subdirectory + // of /tmp/sqlite-database-integration. Move it to SQLITE_PLUGIN_FOLDER. + const temporarySqlitePluginFolder = `/tmp/sqlite-database-integration/${ + (await php.listFiles('/tmp/sqlite-database-integration'))[0] + }`; + await php.mv(temporarySqlitePluginFolder, SQLITE_PLUGIN_FOLDER); + + await relaxSqliteDriverSqlModes(php, SQLITE_PLUGIN_FOLDER); + + // Prevents the SQLite integration from trying to call activate_plugin() + await php.defineConstant('SQLITE_MAIN_FILE', '1'); + const dbCopy = await php.readFileAsText( + joinPaths(SQLITE_PLUGIN_FOLDER, 'db.copy') + ); + let dbPhp = dbCopy + .replace( + "'{SQLITE_IMPLEMENTATION_FOLDER_PATH}'", + phpVar(SQLITE_PLUGIN_FOLDER) + ) + .replace( + "'{SQLITE_PLUGIN}'", + phpVar(joinPaths(SQLITE_PLUGIN_FOLDER, 'load.php')) + ); + + // When loaded via the lazy $wpdb loader on WP < 3.1, the hook + // API isn't available yet. Skip top-level add_action() calls + // in that window; the multiline anchor matches all formattings. + dbPhp = dbPhp.replace( + /^add_action\(/gm, + 'function_exists("add_action") && add_action(' + ); + + const dbPhpPath = joinPaths(await php.documentRoot, 'wp-content/db.php'); + const SQLITE_MUPLUGIN_PATH = + '/internal/shared/mu-plugins/sqlite-database-integration.php'; + + // Recognise our own @playground-managed db.php marker so the + // preload doesn't skip itself on its own drop-in — only a + // real user-supplied db.php should abort. + const dbPhpGuard = ` +if(file_exists(${phpVar(dbPhpPath)})) { + $_pg_db_php = @file_get_contents(${phpVar(dbPhpPath)}); + if (strpos($_pg_db_php, '@playground-managed') === false) { + return; + } + unset($_pg_db_php); +} +`; + + await php.writeFile(SQLITE_MUPLUGIN_PATH, `` + dbPhp); + await php.writeFile( + `/internal/shared/preload/0-sqlite.php`, + buildLegacySqlitePreload(dbPhpGuard, SQLITE_MUPLUGIN_PATH) + ); + + /** + * Ensure the SQLite integration is loaded and clearly communicate + * if it isn't. This is useful because WordPress database errors + * may be cryptic and won't mention the SQLite integration. + */ + await php.writeFile( + `/internal/shared/mu-plugins/sqlite-test.php`, + ` { + const driverPath = joinPaths( + sqlitePluginFolder, + 'wp-includes/database/sqlite/class-wp-pdo-mysql-on-sqlite.php' + ); + if (!(await php.fileExists(driverPath))) return; + const content = await php.readFileAsText(driverPath); + // Two source variants: multi-line `private` in the standard build, + // single-line `public` in the PHP 5.2-downgraded build. The regex + // normalises both to an empty array literal. + const patched = content.replace( + /\$active_sql_modes\s*=\s*array\s*\([^)]*\)\s*;/, + '$active_sql_modes = array();' + ); + if (patched !== content) { + await php.writeFile(driverPath, patched); + } +} + +/** + * Builds the 0-sqlite.php preload content for legacy PHP (< 7). + * Includes MySQL/MySQLi stubs, str_* polyfills, and error suppression. + */ +function buildLegacySqlitePreload( + dbPhpGuard: string, + muPluginPath: string +): string { + return ` + +reinitialize_sqlite(); + }` +)} +${MYSQL_SHIMS_PHP} +if (PHP_MAJOR_VERSION < 7) { + // E_DEPRECATED (8192) / E_STRICT (2048) are PHP 5.3+ symbols; + // LEGACY_WP_ERROR_REPORTING_PHP_EXPR uses numeric literals. + $level = ${LEGACY_WP_ERROR_REPORTING_PHP_EXPR}; + error_reporting($level); + ini_set('error_reporting', $level); +} + + `; +} diff --git a/packages/playground/wordpress/src/legacy-wp/mysql-shims.ts b/packages/playground/wordpress/src/legacy-wp/mysql-shims.ts new file mode 100644 index 00000000000..b652a8f125d --- /dev/null +++ b/packages/playground/wordpress/src/legacy-wp/mysql-shims.ts @@ -0,0 +1,158 @@ +/** + * mysql_* / mysqli_* function stubs for legacy WordPress (<3.0) whose + * wpdb::__construct calls mysql_connect() / mysqli_init() inline and + * bail()s on a falsy return, and for WP 1.x code paths that call + * mysql_query() / mysql_list_tables() / mysql_fetch_row() directly + * (bypassing $wpdb). + * + * Result sets returned by mysql_query() / mysql_list_tables() live in + * the $_mysql_results global keyed by an integer id; mysql_fetch_* + * consume rows from there. + * + * Every stub is function_exists()-guarded so the constant is safe to + * include alongside real ext/mysql or to interpolate twice. + * + * Interpolated into the 0-sqlite.php preload built by + * {@link buildLegacySqlitePreload} in legacy-sqlite-preload.ts. + */ +export const MYSQL_SHIMS_PHP = ` +// Connection stubs — wpdb::__construct bails on a falsy return. +if (!function_exists('mysqli_connect')) { + function mysqli_connect() { return true; } +} +if (!function_exists('mysqli_init')) { + function mysqli_init() { return true; } +} +if (!function_exists('mysql_connect')) { + function mysql_connect() { return true; } +} +if (!function_exists('mysql_select_db')) { + function mysql_select_db() { return true; } +} +// WordPress < 3.0 wpdb::__construct calls mysql_set_charset directly. +if (!function_exists('mysql_set_charset')) { + function mysql_set_charset() { return true; } +} +// Functional mysql_* stubs that delegate to $wpdb (SQLite driver). +$GLOBALS['_mysql_results'] = array(); +$GLOBALS['_mysql_result_id'] = 0; +if (!function_exists('mysql_query')) { + function mysql_query($query, $link = null) { + global $wpdb; + if (isset($wpdb) && method_exists($wpdb, 'query')) { + $wpdb->query($query); + if (preg_match('/^\\s*(SELECT|SHOW|DESCRIBE|EXPLAIN)/i', $query)) { + $rows = isset($wpdb->last_result) ? $wpdb->last_result : array(); + $id = ++$GLOBALS['_mysql_result_id']; + $GLOBALS['_mysql_results'][$id] = array( + 'rows' => $rows, + 'index' => 0, + ); + return $id; + } + return true; + } + return false; + } +} +if (!function_exists('mysql_error')) { + function mysql_error($link = null) { + global $wpdb; + if (isset($wpdb) && isset($wpdb->last_error)) { + return $wpdb->last_error; + } + return ''; + } +} +if (!function_exists('mysql_list_tables')) { + function mysql_list_tables($db = '', $link = null) { + global $wpdb; + if (isset($wpdb) && method_exists($wpdb, 'get_results')) { + $tables = $wpdb->get_results( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + ); + $rows = array(); + if ($tables) { + foreach ($tables as $t) { + $obj = new stdClass(); + $obj->name = is_object($t) ? $t->name : $t['name']; + $rows[] = $obj; + } + } + $id = ++$GLOBALS['_mysql_result_id']; + $GLOBALS['_mysql_results'][$id] = array( + 'rows' => $rows, + 'index' => 0, + ); + return $id; + } + return false; + } +} +if (!function_exists('mysql_fetch_row')) { + function mysql_fetch_row($result) { + if (!isset($GLOBALS['_mysql_results'][$result])) return null; + $r = &$GLOBALS['_mysql_results'][$result]; + if ($r['index'] >= count($r['rows'])) return null; + $row = $r['rows'][$r['index']++]; + return array_values((array)$row); + } +} +if (!function_exists('mysql_fetch_object')) { + function mysql_fetch_object($result) { + if (!isset($GLOBALS['_mysql_results'][$result])) return null; + $r = &$GLOBALS['_mysql_results'][$result]; + if ($r['index'] >= count($r['rows'])) return null; + return (object)(array)$r['rows'][$r['index']++]; + } +} +if (!function_exists('mysql_num_rows')) { + function mysql_num_rows($result) { + if (isset($GLOBALS['_mysql_results'][$result])) { + return count($GLOBALS['_mysql_results'][$result]['rows']); + } + return 0; + } +} +if (!function_exists('mysql_get_server_info')) { + function mysql_get_server_info() { return '8.0.0'; } +} +if (!function_exists('mysql_affected_rows')) { + function mysql_affected_rows() { + global $wpdb; + if (isset($wpdb) && isset($wpdb->rows_affected)) { + return $wpdb->rows_affected; + } + return 0; + } +} +if (!function_exists('mysql_insert_id')) { + function mysql_insert_id() { + global $wpdb; + if (isset($wpdb) && isset($wpdb->insert_id)) { + return $wpdb->insert_id; + } + return 0; + } +} +if (!function_exists('mysql_free_result')) { + function mysql_free_result($result) { + unset($GLOBALS['_mysql_results'][$result]); + return true; + } +} +if (!function_exists('mysql_num_fields')) { + function mysql_num_fields($result) { + if (isset($GLOBALS['_mysql_results'][$result]) + && !empty($GLOBALS['_mysql_results'][$result]['rows'])) { + return count((array)$GLOBALS['_mysql_results'][$result]['rows'][0]); + } + return 0; + } +} +if (!function_exists('mysql_real_escape_string')) { + function mysql_real_escape_string($s) { return addslashes($s); } +} +if (!function_exists('mysql_escape_string')) { + function mysql_escape_string($s) { return addslashes($s); } +}`; diff --git a/packages/playground/wordpress/src/platform-mu-plugins.ts b/packages/playground/wordpress/src/platform-mu-plugins.ts new file mode 100644 index 00000000000..416f884921b --- /dev/null +++ b/packages/playground/wordpress/src/platform-mu-plugins.ts @@ -0,0 +1,212 @@ +import type { UniversalPHP } from '@php-wasm/universal'; + +/** + * Writes the Playground platform mu-plugins that run on every + * supported PHP/WordPress combination — 0-playground.php, + * sitemap-redirect.php, inline-tinymce-content-css.php. + * + * Both {@link setupPlatformLevelMuPlugins} (modern path) and + * {@link setupLegacyPlatformLevelMuPlugins} call this to avoid + * duplicating ~200 lines of identical PHP strings. + */ +export async function writeCommonPlatformMuPlugins( + php: UniversalPHP +): Promise { + await php.writeFile( + '/internal/shared/mu-plugins/0-playground.php', + ` 'sqlite', + 'path' => FQDB, + 'driver_path' => defined('WP_MYSQL_ON_SQLITE_LOADER_PATH') + ? WP_MYSQL_ON_SQLITE_LOADER_PATH + : dirname(SQLITE_MAIN_FILE) . '/wp-pdo-mysql-on-sqlite.php', + ); + } else { + $db_info = array( + 'type' => 'mysql', + // TODO: Save MySQL connection config. + ); + } + $wp_env = array('db' => $db_info); + $wp_env_php = sprintf('flush_rules(); + } + @file_put_contents($flag, '1'); + } + add_action('init', 'playground_maybe_flush_rewrite_rules', 99999); + + // Create the fonts directory if missing + if(!file_exists(WP_CONTENT_DIR . '/fonts')) { + mkdir(WP_CONTENT_DIR . '/fonts'); + } + + $log_file = WP_CONTENT_DIR . '/debug.log'; + if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { + if ( is_string( WP_DEBUG_LOG ) ) { + $log_file = WP_DEBUG_LOG; + } + ini_set('error_log', $log_file); + } else { + ini_set('log_errors', '0'); + } + define('ERROR_LOG_FILE', $log_file); + ?>` + ); + + /** + * WordPress 6.7+ only generates the sitemap.xml → wp-sitemap.xml rewrite + * rule when installed at the domain root. Since Playground may use non-root + * installations, the rule isn't generated. This mu-plugin handles the + * redirect manually by using the site URL to determine the correct base path. + * + * @see https://github.com/WordPress/wordpress-playground/issues/2051 + */ + await php.writeFile( + '/internal/shared/mu-plugins/sitemap-redirect.php', + `load_sqlite_integration(); + if($GLOBALS['wpdb'] === $this) { + throw new Exception('Infinite loop detected in $wpdb – SQLite integration plugin could not be loaded'); + } + return call_user_func_array( + array($GLOBALS['wpdb'], $name), + $arguments + ); + } + public function __get($name) { + $this->load_sqlite_integration(); + if($GLOBALS['wpdb'] === $this) { + throw new Exception('Infinite loop detected in $wpdb – SQLite integration plugin could not be loaded'); + } + return $GLOBALS['wpdb']->$name; + } + public function __set($name, $value) { + $this->load_sqlite_integration(); + if($GLOBALS['wpdb'] === $this) { + throw new Exception('Infinite loop detected in $wpdb – SQLite integration plugin could not be loaded'); + } + $GLOBALS['wpdb']->$name = $value; + } + protected function load_sqlite_integration() { + ${loadBody} + } +} +/** + * The Query Monitor plugin short-circuits in the CLI SAPI. However, in Playground, + * the SAPI is always "cli" at the moment. Let's set a constant to disable the CLI + * detection. + * + * @see https://github.com/WordPress/sqlite-database-integration/pull/212 + * @see https://github.com/WordPress/sqlite-database-integration/pull/215 + */ +define('QM_TESTS', true); +$wpdb = $GLOBALS['wpdb'] = new Playground_SQLite_Integration_Loader(); + +/** + * WordPress is capable of using a preloaded global $wpdb. However, if + * it cannot find the drop-in db.php plugin it still checks whether + * the mysqli_connect() function exists even though it's not used. + * + * What WordPress demands, Playground shall provide. + */ +`; +} diff --git a/packages/playground/wordpress/tests/test-legacy-wp-version-boot.mjs b/packages/playground/wordpress/tests/test-legacy-wp-version-boot.mjs new file mode 100644 index 00000000000..a23935f3ddb --- /dev/null +++ b/packages/playground/wordpress/tests/test-legacy-wp-version-boot.mjs @@ -0,0 +1,744 @@ +/** + * Tests that legacy and mid-modern WordPress versions boot + * successfully through Playground's wordpress.org download path: + * + * - WP 1.0 – 4.9 on PHP 5.2 (legacy SQLite driver) + * - WP 5.0 – 6.2 on PHP 7.4 (modern SQLite driver) + * + * Pre-built bundled WP (6.3+) has its own coverage elsewhere. + * + * Each version is exercised through five phases: + * + * 1. Front page loads with "Hello world!" + * 2. wp-admin dashboard loads (auto-login works) + * 3. Clicking a post title loads the single post (pretty permalinks) + * 4. Creating a new post page loads (nonces work) + * 5. Activating a plugin works (Hello Dolly) + * + * All failures are hard errors: the job should honestly reflect the + * state of legacy WordPress support. + * + * Requires the dev server to be running on port 5400 + * (started by the CI job or manually via `npm run dev`). + * + * Usage: node packages/playground/wordpress/tests/test-legacy-wp-version-boot.mjs + */ +import { chromium } from 'playwright'; + +// Matrix of (WordPress, PHP) combinations to test. +// Versions that were never released: 1.1, 1.3, 1.4, 2.4. +// The web worker normalizes bare versions automatically (1.5 → 1.5.2, +// 2.0 → 2.0.11, etc.) and resolves them to wordpress.org downloads. +// Modern WP (5.0–6.2) is paired with PHP 7.4 because it's the newest +// PHP the legacy SQLite driver supports and is far enough from the +// PHP 5.2 leg to make regressions obvious. +const WP_VERSIONS = [ + // Mid-modern WordPress (PHP 7.4). + { wp: '6.2', php: '7.4' }, + { wp: '6.1', php: '7.4' }, + { wp: '6.0', php: '7.4' }, + { wp: '5.9', php: '7.4' }, + { wp: '5.8', php: '7.4' }, + { wp: '5.7', php: '7.4' }, + { wp: '5.6', php: '7.4' }, + { wp: '5.5', php: '7.4' }, + { wp: '5.4', php: '7.4' }, + { wp: '5.3', php: '7.4' }, + { wp: '5.2', php: '7.4' }, + { wp: '5.1', php: '7.4' }, + { wp: '5.0', php: '7.4' }, + // Legacy WordPress on PHP 5.2 WASM. + { wp: '4.9', php: '5.2' }, + { wp: '4.8', php: '5.2' }, + { wp: '4.7', php: '5.2' }, + { wp: '4.6', php: '5.2' }, + { wp: '4.5', php: '5.2' }, + { wp: '4.4', php: '5.2' }, + { wp: '4.3', php: '5.2' }, + { wp: '4.2', php: '5.2' }, + { wp: '4.1', php: '5.2' }, + { wp: '4.0', php: '5.2' }, + { wp: '3.9', php: '5.2' }, + { wp: '3.8', php: '5.2' }, + { wp: '3.7', php: '5.2' }, + { wp: '3.6', php: '5.2' }, + { wp: '3.5', php: '5.2' }, + { wp: '3.4', php: '5.2' }, + { wp: '3.3', php: '5.2' }, + { wp: '3.2', php: '5.2' }, + { wp: '3.1', php: '5.2' }, + { wp: '3.0', php: '5.2' }, + { wp: '2.9', php: '5.2' }, + { wp: '2.8', php: '5.2' }, + { wp: '2.7', php: '5.2' }, + { wp: '2.6', php: '5.2' }, + { wp: '2.5', php: '5.2' }, + { wp: '2.3', php: '5.2' }, + { wp: '2.2', php: '5.2' }, + { wp: '2.1', php: '5.2' }, + { wp: '2.0', php: '5.2' }, + { wp: '1.5', php: '5.2' }, + { wp: '1.2', php: '5.2' }, + { wp: '1.0', php: '5.2' }, +]; + +const PORT = 5400; +const TIMEOUT_S = 120; +const results = []; + +/** + * Finds the WordPress content frame (the one whose URL contains "scope:") + * and returns its body text once it has meaningful content. + * + * Options: + * - `excludeUrl`: skip the scoped frame while its URL still matches the + * pre-click value — lets callers wait for a post-click navigation to + * actually commit instead of racing against the previous page's body. + * - `contentPredicate`: a `(body) => boolean` that must also return + * true before we return. Without this, any body ≥ 20 chars counts, + * which is too eager on slow CI boots where pages render in stages + * (e.g. admin shell first, plugin list later). + * + * Returns null on timeout. + */ +async function waitForWPFrame(page, timeoutSeconds, opts = {}) { + const { excludeUrl = null, contentPredicate = null } = opts; + const iterations = Math.ceil((timeoutSeconds * 1000) / 500); + for (let i = 0; i < iterations; i++) { + await page.waitForTimeout(500); + for (const frame of page.frames()) { + try { + const furl = frame.url(); + if (!furl.includes('scope:')) continue; + if (excludeUrl && furl === excludeUrl) continue; + const body = await frame + .locator('body') + .innerText({ timeout: 2000 }); + if (!body || body.length < 20) continue; + if (contentPredicate && !contentPredicate(body)) continue; + return { body, frame }; + } catch {} + } + } + return null; +} + +/** + * Checks body text for PHP errors. + * Returns the full error line (including file path and line number) + * if found, null otherwise. The returned string is not truncated — + * callers decide how much to display. + */ +function findPHPError(body) { + const errorPatterns = ['Parse error', 'Fatal error', 'database error']; + for (const pattern of errorPatterns) { + if (body.includes(pattern)) { + const line = body + .split('\n') + .find((l) => l.includes(pattern)) + ?.trim(); + return line || body.slice(0, 500).trim(); + } + } + return null; +} + +/** + * After navigating to a new-post admin page, waits for the editor + * form to finish rendering. + * + * WP 4.1 introduced auto-draft creation: `post-new.php` calls + * `get_default_post_to_edit($type, true)` which runs `wp_insert_post()` + * before emitting the editor form. The PHP process streams the admin + * navigation chrome immediately (the WP header fires before the insert), + * so `navigateViaUrlBar` can return while the page is only half- + * rendered. This helper polls the frame's full HTML until the + * `name="post_title"` input appears, a PHP error is detected, or the + * timeout expires. + * + * All other WP versions that use a new-post path already render the + * editor in the very first chunk, so the extra wait is a no-op for them. + */ +async function waitForNewPostEditorHtml(frame, timeoutSeconds = 30) { + // Covers the classic editor (WP < 5.0, which renders a plain + // ) and Gutenberg (WP 5.0+, which emits + // a block editor container and React bootstrap scripts). Any one + // of these strings in the initial HTML means the post-new.php + // response reached the editor render stage successfully. + const editorMarkers = [ + 'name="post_title"', + "name='post_title'", + 'id="editor"', + 'edit-post-layout', + 'block-editor-writing-flow', + ]; + const deadline = Date.now() + timeoutSeconds * 1000; + let html = ''; + while (Date.now() < deadline) { + try { + html = await frame.locator('body').innerHTML({ timeout: 3000 }); + } catch { + await frame.page().waitForTimeout(500); + continue; + } + if (editorMarkers.some((m) => html.includes(m))) return html; + if (findPHPError(html)) return html; + await frame.page().waitForTimeout(1000); + } + // Return whatever we have when the deadline expires so the caller can + // still classify the result (e.g. UNKNOWN / TIMEOUT) rather than + // crashing. + return html; +} + +/** + * Navigates inside the Playground via the URL bar and then waits for + * the WordPress content frame to actually navigate to `path`. + * + * The previous implementation only waited for *any* scoped frame to + * have body content, which gave false positives when the navigation + * silently failed (e.g. a 25s service worker timeout on post.php) and + * left the iframe on the previous page. We now wait for the frame URL + * to match `path` (or a redirect target different from the previous + * URL) before returning, so stale content is never reported as OK. + */ +async function navigateViaUrlBar(page, path, timeoutSeconds = 60) { + // Capture the frame URL we're navigating away from so we can tell + // when the actual navigation commits (or when a redirect lands us + // on a different page than the previous one). + const scopedBefore = page.frames().find((f) => f.url().includes('scope:')); + const urlBefore = scopedBefore?.url() || ''; + + const urlBar = page.locator('input[name="url"]'); + await urlBar.fill(path); + await urlBar.press('Enter'); + + // Poll for the scoped frame URL to change. Accept either: + // (a) the URL now includes the requested `path`, or + // (b) the URL is different from `urlBefore` (covers 302 redirects + // that land on a sibling page, e.g. WP 2.1's post.php → edit.php). + const deadline = Date.now() + timeoutSeconds * 1000; + while (Date.now() < deadline) { + await page.waitForTimeout(500); + const frame = page.frames().find((f) => f.url().includes('scope:')); + if (!frame) continue; + const url = frame.url(); + const pathStem = path.split('?')[0].split('#')[0]; + const committed = url.includes(pathStem) || url !== urlBefore; + if (!committed) continue; + try { + const body = await frame + .locator('body') + .innerText({ timeout: 2000 }); + if (body && body.length >= 20) { + return { body, frame }; + } + } catch {} + } + return null; +} + +/** + * Checks whether a body text indicates the user is logged in. + */ +function isLoggedIn(body) { + return ['Logout', 'Log Out', 'Sign Out', 'Howdy'].some((s) => + body.includes(s) + ); +} + +// WP < 2.5 uses post.php for new posts; 2.5+ uses post-new.php. +// WP 1.0-2.0 render the "new post" form via wp-admin/post.php's +// default case. WP 2.1 introduced wp-admin/post-new.php and made +// post.php redirect to edit.php, so the new-post form lives at +// post-new.php from 2.1 onward (just like modern WordPress). +const NEW_POST_URL_VERSIONS = new Set(['1.0', '1.2', '1.5', '2.0']); + +// Optional filter for local runs: WP_ONLY=6.2,6.1,5.9 to test a subset. +const WP_ONLY = process.env.WP_ONLY + ? new Set(process.env.WP_ONLY.split(',').map((s) => s.trim())) + : null; +const MATRIX = WP_ONLY + ? WP_VERSIONS.filter(({ wp }) => WP_ONLY.has(wp)) + : WP_VERSIONS; + +const browser = await chromium.launch({ headless: true }); + +for (const { wp, php } of MATRIX) { + const label = `WP ${wp} (PHP ${php})`; + process.stdout.write(`${label}... `); + + const url = `http://127.0.0.1:${PORT}/website-server/?php=${php}&wp=${wp}`; + + // Isolate every version in a fresh browser context so that OPFS + // (where Playground persists site state), IndexedDB, localStorage + // and cookies don't leak between versions. Without this, earlier + // versions' patched files and scopes bleed into later ones and + // the test becomes non-deterministic. + const context = await browser.newContext(); + const page = await context.newPage(); + const consoleErrors = []; + page.on('console', (msg) => { + if (msg.type() === 'error') + consoleErrors.push(msg.text().slice(0, 300)); + }); + + let frontStatus = null; + let adminStatus = null; + let postStatus = null; + let newPostStatus = null; + let pluginStatus = null; + + try { + await page.goto(url, { + timeout: 180_000, + waitUntil: 'domcontentloaded', + }); + + // --- Phase 1: Front page --- + const wp1 = await waitForWPFrame(page, TIMEOUT_S); + + if (!wp1) { + const lastError = consoleErrors[consoleErrors.length - 1] || ''; + frontStatus = { + status: 'TIMEOUT', + detail: lastError, + }; + } else { + const error = findPHPError(wp1.body); + if (error) { + frontStatus = { + status: 'ERROR', + detail: error, + body: wp1.body, + }; + } else { + const hasHelloWorld = + wp1.body.includes('Hello world') || + wp1.body.includes('Hello World'); + const hasWP = + wp1.body.includes('WordPress') || + wp1.body.includes('My WordPress') || + wp1.body.includes('My Weblog'); + + if (hasHelloWorld) { + frontStatus = { status: 'OK' }; + } else if (wp1.body.includes('Not Found') && !hasHelloWorld) { + frontStatus = { status: 'NOT_FOUND', body: wp1.body }; + } else if (hasWP) { + frontStatus = { + status: 'PARTIAL', + detail: wp1.body.slice(0, 120).replace(/\n/g, ' '), + }; + } else { + frontStatus = { + status: 'UNKNOWN', + detail: wp1.body.slice(0, 120).replace(/\n/g, ' '), + body: wp1.body, + }; + } + } + } + + // --- Phase 2: View single post (click "Hello world!") --- + if ( + wp1 && + (frontStatus.status === 'OK' || frontStatus.status === 'PARTIAL') + ) { + try { + const link = wp1.frame + .getByRole('link', { + name: 'Hello world!', + exact: true, + }) + .first(); + if ((await link.count()) > 0) { + const prevFrameUrl = wp1.frame.url(); + await link.click({ timeout: 5000 }); + const wp1b = await waitForWPFrame(page, 30, { + excludeUrl: prevFrameUrl, + // Don't return on the transient redirect body — wait + // until the single-post page actually renders (or + // WordPress emits its own not-found message). + contentPredicate: (body) => + body.includes('Welcome to WordPress') || + body.includes('Hello world') || + body.includes('Not Found') || + body.includes("can't find"), + }); + if (!wp1b) { + postStatus = { status: 'TIMEOUT' }; + } else { + const hasContent = + (wp1b.body.includes('Welcome to WordPress') || + wp1b.body.includes('Hello world')) && + !wp1b.body.includes('Not Found') && + !wp1b.body.includes("can't find"); + postStatus = hasContent + ? { status: 'OK' } + : { + status: 'NOT_FOUND', + detail: wp1b.body + .slice(0, 120) + .replace(/\n/g, ' '), + }; + } + } else { + postStatus = { status: 'SKIP', detail: 'no link found' }; + } + } catch (e) { + postStatus = { status: 'CRASH', detail: e.message }; + } + } else { + postStatus = { status: 'SKIP', detail: 'front page failed' }; + } + + // --- Phase 3: Admin dashboard (auto-login) --- + if (frontStatus.status === 'OK' || frontStatus.status === 'PARTIAL') { + try { + // Retry once on timeout — modern WP admin occasionally + // hangs the first /wp-admin/ load on shared CI runners + // (see the long-standing admin-phase flake across runs + // on 5.5, 5.9, 6.0, ...). One fresh fill+Enter of the + // URL bar almost always unblocks it. + let wp2 = await navigateViaUrlBar( + page, + '/wp-admin/', + TIMEOUT_S + ); + if (!wp2) { + wp2 = await navigateViaUrlBar( + page, + '/wp-admin/', + TIMEOUT_S + ); + } + if (!wp2) { + adminStatus = { status: 'TIMEOUT' }; + } else { + const error = findPHPError(wp2.body); + if (error) { + adminStatus = { + status: 'ERROR', + detail: error, + body: wp2.body, + }; + } else { + const adminIndicators = [ + 'Dashboard', + 'Write', + 'Manage', + 'Options', + 'Log Out', + 'Logout', + 'Settings', + 'Posts', + 'Plugins', + 'Create New Post', + 'My Profile', + ]; + const hasAdmin = adminIndicators.some((ind) => + wp2.body.includes(ind) + ); + const loggedIn = isLoggedIn(wp2.body); + if (hasAdmin && loggedIn) { + adminStatus = { status: 'OK' }; + } else if (hasAdmin) { + adminStatus = { + status: 'OK', + detail: 'admin loaded but login state unclear', + }; + } else { + adminStatus = { + status: 'UNKNOWN', + detail: wp2.body + .slice(0, 120) + .replace(/\n/g, ' '), + body: wp2.body, + }; + } + } + } + } catch (e) { + adminStatus = { + status: 'CRASH', + detail: e.message, + }; + } + } else { + adminStatus = { status: 'SKIP', detail: 'front page failed' }; + } + + // --- Phase 4: New post page (nonce check) --- + if (adminStatus && adminStatus.status === 'OK') { + try { + const newPostPath = NEW_POST_URL_VERSIONS.has(wp) + ? '/wp-admin/post.php' + : '/wp-admin/post-new.php'; + const wp3 = await navigateViaUrlBar(page, newPostPath, 30); + if (!wp3) { + newPostStatus = { status: 'TIMEOUT' }; + } else { + // Check both innerText and innerHTML for PHP + // errors — some errors land inside hidden elements + // (e.g. WP 3.3's contextual-help sidebar) and + // don't appear in innerText. + // waitForNewPostEditorHtml polls until the editor form + // is fully rendered; this is needed for WP 4.1 which + // creates an auto-draft before emitting the form, so + // the nav chrome arrives before the editor body. + const html = await waitForNewPostEditorHtml(wp3.frame, 30); + // Use the fully-rendered HTML for all checks. + // wp3.body (innerText) may only contain the admin + // navigation chrome if PHP was still running when + // navigateViaUrlBar returned (e.g. WP 4.1 auto-draft). + const bodyText = await wp3.frame + .locator('body') + .innerText({ timeout: 2000 }) + .catch(() => wp3.body); + + const error = findPHPError(bodyText) || findPHPError(html); + + const bad = + bodyText.includes('Are you sure') || + bodyText.includes('not allowed') || + bodyText.includes('sufficient permissions'); + // Require a marker that actually indicates the new + // post editor (not random dashboard nav strings). + // `` is present on every WP + // that uses the classic editor (WP 1.0–4.9 and WP 5.0+ + // when Gutenberg is disabled); `id="editor"`, + // `edit-post-layout` and `block-editor-writing-flow` + // cover the Gutenberg path shipped from WP 5.0 onward. + // The visible editor headings cover header variants. + const hasEditor = + html.includes('name="post_title"') || + html.includes("name='post_title'") || + html.includes('id="editor"') || + html.includes('edit-post-layout') || + html.includes('block-editor-writing-flow') || + bodyText.includes('Write Post') || + bodyText.includes('Add New Post') || + bodyText.includes('Create New Post') || + bodyText.includes('New Post'); + if (error) { + newPostStatus = { + status: 'ERROR', + detail: error, + }; + } else if (bad) { + newPostStatus = { + status: 'NONCE_FAIL', + detail: bodyText.includes('Are you sure') + ? 'nonce verification failed' + : 'permission denied', + }; + } else if (hasEditor) { + newPostStatus = { status: 'OK' }; + } else { + newPostStatus = { + status: 'UNKNOWN', + detail: bodyText.slice(0, 120).replace(/\n/g, ' '), + }; + } + } + } catch (e) { + newPostStatus = { status: 'CRASH', detail: e.message }; + } + } else { + newPostStatus = { status: 'SKIP', detail: 'admin failed' }; + } + + // --- Phase 5: Plugin activation --- + if (adminStatus && adminStatus.status === 'OK') { + try { + const wp4 = await navigateViaUrlBar( + page, + '/wp-admin/plugins.php', + 30 + ); + if (!wp4) { + pluginStatus = { status: 'TIMEOUT' }; + } else { + // Target Hello Dolly specifically via its href. Clicking + // the *first* Activate link lands on Akismet's setup + // page on modern WP, which doesn't include the + // "Deactivate"/"Plugin activated" indicators this phase + // looks for. Hello Dolly ("hello.php") ships with every + // modern WordPress release and activates in-place with + // no follow-up screen, so the resulting plugins.php + // reliably shows the expected confirmation. + // Fall back to the first Activate link for very old WP + // where Hello Dolly may not be present or the href + // format differs. + // Wait for any Activate link to render — navigateViaUrlBar + // returns as soon as plugins.php has *any* body text, which + // on slow CI boots can be just the admin shell before the + // plugin list renders. + const anyActivate = wp4.frame + .locator('a') + .filter({ hasText: 'Activate' }) + .first(); + try { + await anyActivate.waitFor({ + state: 'visible', + timeout: 15000, + }); + } catch {} + const helloActivate = wp4.frame + .locator('a[href*="hello.php"]') + .filter({ hasText: 'Activate' }) + .first(); + const activateLink = + (await helloActivate.count()) > 0 + ? helloActivate + : anyActivate; + if ((await activateLink.count()) > 0) { + const prevFrameUrl = wp4.frame.url(); + await activateLink.click({ timeout: 5000 }); + const wp4b = await waitForWPFrame(page, 20, { + excludeUrl: prevFrameUrl, + // Don't match on the intermediate admin shell + // between the POST and the post-redirect body — + // only return once the result page actually + // shows activation outcome text. + contentPredicate: (body) => + body.includes('Plugin activated') || + body.includes('Deactivate') || + body.includes('Are you sure'), + }); + if (!wp4b) { + pluginStatus = { status: 'TIMEOUT' }; + } else { + const ok = + wp4b.body.includes('Plugin activated') || + wp4b.body.includes('Deactivate'); + const bad = wp4b.body.includes('Are you sure'); + pluginStatus = ok + ? { status: 'OK' } + : { + status: bad ? 'NONCE_FAIL' : 'UNKNOWN', + detail: wp4b.body + .slice(0, 120) + .replace(/\n/g, ' '), + }; + } + } else { + pluginStatus = { + status: 'SKIP', + detail: 'no activate link found', + }; + } + } + } catch (e) { + pluginStatus = { status: 'CRASH', detail: e.message }; + } + } else { + pluginStatus = { status: 'SKIP', detail: 'admin failed' }; + } + } catch (e) { + frontStatus = { + status: 'CRASH', + detail: e.message, + }; + adminStatus = { status: 'SKIP', detail: 'boot crashed' }; + postStatus = { status: 'SKIP', detail: 'boot crashed' }; + newPostStatus = { status: 'SKIP', detail: 'boot crashed' }; + pluginStatus = { status: 'SKIP', detail: 'boot crashed' }; + } + + const icon = (s) => + s.status === 'OK' ? '✓' : s.status === 'SKIP' ? '-' : '✗'; + const parts = [ + `front:${icon(frontStatus)}`, + `post:${icon(postStatus)}`, + `admin:${icon(adminStatus)}`, + `newpost:${icon(newPostStatus)}`, + `plugin:${icon(pluginStatus)}`, + ]; + console.log(parts.join(' ')); + + results.push({ + wp, + php, + front: frontStatus, + post: postStatus, + admin: adminStatus, + newPost: newPostStatus, + plugin: pluginStatus, + }); + await page.close(); + await context.close(); +} + +await browser.close(); + +const PHASES = ['front', 'post', 'admin', 'newPost', 'plugin']; + +function isPass(status) { + return status.status === 'OK' || status.status === 'PARTIAL'; +} +function isSkip(status) { + return status.status === 'SKIP'; +} + +console.log(`\n${'='.repeat(70)}`); +console.log('RESULTS SUMMARY:'); +console.log(`${'='.repeat(70)}`); +for (const r of results) { + const cols = PHASES.map((p) => { + const s = r[p]; + if (!s) return '-'; + if (isPass(s)) return 'PASS'; + if (isSkip(s)) return 'skip'; + return 'FAIL'; + }); + console.log( + ` WP ${r.wp.padEnd(5)} (PHP ${r.php}) ${cols.map((c, i) => `${PHASES[i]}:${c}`).join(' ')}` + ); +} + +const counts = {}; +for (const p of PHASES) { + const tested = results.filter((r) => r[p] && !isSkip(r[p])); + const passed = tested.filter((r) => isPass(r[p])); + counts[p] = { tested: tested.length, passed: passed.length }; +} +console.log(''); +for (const p of PHASES) { + console.log(` ${p.padEnd(8)}: ${counts[p].passed}/${counts[p].tested} OK`); +} + +// Dump per-failure diagnostic bodies. +const failures = results.filter((r) => + PHASES.some((p) => r[p] && !isPass(r[p]) && !isSkip(r[p])) +); +if (failures.length > 0) { + console.log(`\n${'='.repeat(70)}`); + console.log('FAILURE DETAILS:'); + console.log(`${'='.repeat(70)}`); + for (const r of failures) { + console.log(`\n--- WP ${r.wp} (PHP ${r.php}) ---`); + for (const p of PHASES) { + const s = r[p]; + if (!s || isPass(s) || isSkip(s)) continue; + console.log(` ${p} [${s.status}]: ${s.detail || ''}`); + if (s.body) { + console.log( + ` body:\n${s.body.slice(0, 1000).replace(/^/gm, ' ')}` + ); + } + } + } +} + +// All non-skip failures are hard errors. +const totalFailures = results.reduce( + (n, r) => + n + PHASES.filter((p) => r[p] && !isPass(r[p]) && !isSkip(r[p])).length, + 0 +); +if (totalFailures > 0) { + console.error(`\n${totalFailures} failure(s) across all phases.`); + process.exit(1); +} diff --git a/scripts/patch-sqlite-for-php52.mjs b/scripts/patch-sqlite-for-php52.mjs new file mode 100644 index 00000000000..269cbc282ff --- /dev/null +++ b/scripts/patch-sqlite-for-php52.mjs @@ -0,0 +1,625 @@ +/** + * Offline patcher: transforms the upstream SQLite database integration + * plugin into a PHP 5.2-compatible variant. + * + * Pipeline: + * + * 1. Unzip `sqlite-database-integration-v3.0.0-rc.3.zip` to a temp dir. + * 2. Run `scripts/php52-downgrader/bin/downgrade.php` over the dir. + * The downgrader is an AST-based pipeline built on nikic/php-parser + * v5 that handles every mechanical PHP 7+ -> 5.2 rewrite — type + * declarations, null-coalescing, short arrays, closures, etc. + * 3. Apply a small set of per-file surgical fixes for shapes that + * don't survive a pure AST round-trip (PHP_VERSION_ID-gated + * traits, ReflectionProperty access, WP compatibility polyfills, + * etc.). + * 4. Re-zip to `sqlite-database-integration-v3.0.0-rc.3-php52.zip`. + * + * Usage: node scripts/patch-sqlite-for-php52.mjs + * + * Requires a host PHP 7.4+ binary (`php` on PATH) to run the AST + * downgrader, plus `composer` to install the downgrader's own + * dependencies the first time. The `vendor/` directory is not + * committed — running this script will `composer install` from + * `scripts/php52-downgrader/composer.lock` if `vendor/autoload.php` + * is missing. + * + * The compiled SQLite plugin and its PHP 5.2 WASM runtime have no + * dependency on this script at runtime — the generated zip is + * committed and served as-is by the build. + */ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { execSync, spawnSync } from 'child_process'; + +const REPO_ROOT = path.resolve( + path.dirname(new URL(import.meta.url).pathname), + '..' +); +const SRC_ZIP = path.join( + REPO_ROOT, + 'packages/playground/wordpress-builds/src/sqlite-database-integration/sqlite-database-integration-v3.0.0-rc.3.zip' +); +const OUT_ZIP = path.join( + REPO_ROOT, + 'packages/playground/wordpress-builds/src/sqlite-database-integration/sqlite-database-integration-v3.0.0-rc.3-php52.zip' +); +const DOWNGRADER_DIR = path.join(REPO_ROOT, 'scripts/php52-downgrader'); +const DOWNGRADER = path.join(DOWNGRADER_DIR, 'bin/downgrade.php'); +const DOWNGRADER_AUTOLOAD = path.join(DOWNGRADER_DIR, 'vendor/autoload.php'); + +function ensureDowngraderVendor() { + if (fs.existsSync(DOWNGRADER_AUTOLOAD)) { + return; + } + console.log('Installing downgrader dependencies (composer install)...'); + const result = spawnSync( + 'composer', + ['install', '--no-dev', '--no-interaction', '--no-progress'], + { cwd: DOWNGRADER_DIR, stdio: 'inherit' } + ); + if (result.status !== 0) { + throw new Error( + `composer install failed in ${DOWNGRADER_DIR}. ` + + `The PHP 5.2 downgrader needs its composer deps installed. ` + + `Ensure 'composer' is on PATH and re-run this script.` + ); + } +} + +const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'sqlite-php52-patch-')); +try { + ensureDowngraderVendor(); + execSync(`unzip -q "${SRC_ZIP}" -d "${TMP_DIR}"`); + + // Run the AST downgrader. It walks every .php/.copy file and + // rewrites in place. + const pluginRoot = fs + .readdirSync(TMP_DIR, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => path.join(TMP_DIR, e.name))[0]; + if (!pluginRoot) { + throw new Error('zip did not contain a plugin subdirectory'); + } + console.log('Running AST downgrader...'); + const result = spawnSync('php', [DOWNGRADER, pluginRoot], { + stdio: ['ignore', 'inherit', 'inherit'], + }); + if (result.status !== 0) { + throw new Error( + `downgrader failed (exit ${result.status}). ` + + `Requires a host PHP 7.4+ binary named 'php' on PATH.` + ); + } + + // Apply per-file surgical fixes that can't be expressed as AST + // transforms. These run AFTER the downgrader, so they operate on + // the pretty-printed PHP 5.2-compatible output. + console.log('Applying surgical fixes...'); + let surgicalCount = 0; + const files = findFiles(pluginRoot); + for (const filePath of files) { + const rel = path.relative(pluginRoot, filePath); + let content = fs.readFileSync(filePath, 'utf-8'); + const original = content; + + // Rename the reserved `throw` method. AST visitor already + // handles the class definition + call sites, but inline + // references in array literals (`'throw' => 'throw'`) and + // string comparisons slip past — patch those here too. + content = content.replace( + /'throw'(\s*=>\s*)'throw'/g, + "'throw'$1'throwError'" + ); + + // Widen `$allow_unsafe_unquoted_parameters` visibility from + // `private` to `public`. Legacy WordPress (<4.8.3) reads this + // property on wpdb-shaped objects through plain `->property` + // access — not `$this->` — so `protected` would still error on + // PHP 5.2 with "Cannot access protected property". Public is + // therefore the minimum viable relaxation. In-process code + // inside the WASM sandbox can flip this flag, but the + // Playground iframe is a single-trust boundary and the only + // consumer of this build. + content = content.replace( + 'private $allow_unsafe_unquoted_parameters = true;', + 'public $allow_unsafe_unquoted_parameters = true;' + ); + + // Guard WP function calls that may not exist in old WordPress. + // These are WordPress-version dependent, not PHP-version + // dependent, so they can't live in the AST downgrader. + if (filePath.endsWith('class-wp-sqlite-db.php')) { + content = content.replace( + "$query = apply_filters('query', $query);", + "if ( function_exists( 'apply_filters' ) ) { $query = apply_filters( 'query', $query ); }" + ); + content = content.replace( + "$incompatible_modes = (array) apply_filters('incompatible_sql_modes', $this->incompatible_modes);", + "$_modes = isset( $this->incompatible_modes ) ? $this->incompatible_modes : array();\n\t\t$incompatible_modes = function_exists( 'apply_filters' ) ? (array) apply_filters( 'incompatible_sql_modes', $_modes ) : (array) $_modes;" + ); + content = content.replace( + 'wp_load_translations_early();', + "if ( function_exists( 'wp_load_translations_early' ) ) { wp_load_translations_early(); }" + ); + } + + content = content.replace( + /if \( is_multisite\(\) \)/g, + "if ( function_exists('is_multisite') && is_multisite() )" + ); + content = content.replace( + /if \(is_multisite\(\)\)/g, + "if ( function_exists('is_multisite') && is_multisite() )" + ); + content = content.replace( + /if \( is_admin\(\) \)/g, + "if ( function_exists('is_admin') && is_admin() )" + ); + content = content.replace( + /if \(is_admin\(\)\)/g, + "if ( function_exists('is_admin') && is_admin() )" + ); + + if ( + filePath.includes( + 'class-wp-sqlite-information-schema-reconstructor.php' + ) + ) { + content = content.replace( + '$wpdb->set_prefix( $table_prefix );', + "if ( method_exists( $wpdb, 'set_prefix' ) ) { $wpdb->set_prefix( $table_prefix ); }" + ); + // Load wp-admin/includes/schema.php so `wp_get_db_schema()` + // becomes available on the legacy WP install path. + // + // The @require_once + eval fallback looks odd but is load- + // bearing: + // + // 1. `@require_once` suppresses E_STRICT / E_DEPRECATED + // warnings that pre-3.5 WordPress emits when its + // legacy schema.php runs under PHP 5.2 (strict-mode + // method signatures, reassigning $this in globals, + // etc.). Without the @, the include trips a strict + // warning that aborts the legacy install chain. + // + // 2. Some legacy WP branches (pre-3.1) `require` or + // `include` schema.php elsewhere in their bootstrap. + // When that happens before reconstruction runs, + // `require_once` here is a no-op and + // `wp_get_db_schema()` can STILL be undefined — + // because the earlier include landed inside a + // function scope that doesn't expose top-level + // functions, or because the earlier file path + // differs by normalization and PHP's resolved-file + // cache doesn't treat the two includes as the same. + // The `eval('?>' . file_get_contents(...))` branch + // forces the file's global definitions to run a + // second time in the current scope and is the only + // thing that reliably defines `wp_get_db_schema()` + // on WP 2.x–3.0. + // + // DO NOT simplify to a plain `require_once` — doing so + // causes the pre-3.1 install to silently no-op writes, + // leaving a 0-byte SQLite file and every subsequent + // request landing on wp-admin/install.php. (The plain- + // require_once variant also collides with the + // `wp_get_db_schema` `function_exists` guard regex below.) + // + // In-sandbox only: the PHP file system is the ephemeral + // WASM VFS, so a `file_get_contents()` + eval on ABSPATH + // is a local-only bootstrap, not a supply-chain surface. + content = content.replace( + "require_once ABSPATH . 'wp-admin/includes/schema.php';", + "@require_once ABSPATH . 'wp-admin/includes/schema.php'; " + + "if (!function_exists('wp_get_db_schema') && !isset($GLOBALS['wp_queries'])) " + + "{ eval('?>' . file_get_contents(ABSPATH . 'wp-admin/includes/schema.php')); }" + ); + content = content.replace( + /throw new WP_SQLite_Driver_Exception\(\s*\$this->driver,\s*'Failed to parse the MySQL query\.'\s*\);/g, + 'return; // Non-fatal: old WP schema may not parse cleanly' + ); + const fallback = + '(isset($GLOBALS["wp_queries"]) ? $GLOBALS["wp_queries"] : "")'; + content = content + .replace( + "wp_get_db_schema('global')", + `(function_exists("wp_get_db_schema") ? wp_get_db_schema( 'global' ) : ${fallback})` + ) + .replace( + /wp_get_db_schema\('blog', \(int\) \$blog_id\)/g, + `(function_exists("wp_get_db_schema") ? wp_get_db_schema( 'blog', (int) $blog_id ) : ${fallback})` + ) + .replace( + "wp_get_db_schema('blog')", + `(function_exists("wp_get_db_schema") ? wp_get_db_schema( 'blog' ) : ${fallback})` + ); + content = content.replace( + /if \(\s*!\s*function_exists\(\s*'wp_get_db_schema'\s*\)\s*\) \{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/s, + '// wp_get_db_schema polyfill handled inline' + ); + } + + if (filePath.includes('install-functions.php')) { + content = content.replace( + '$table_schemas = wp_get_db_schema();', + '$table_schemas = function_exists("wp_get_db_schema") ? wp_get_db_schema() : (isset($GLOBALS["wp_queries"]) ? $GLOBALS["wp_queries"] : "");' + ); + content = content.replace( + "if (!function_exists('wp_install')) {", + "if ( ! function_exists( 'wp_install' ) && function_exists( 'update_user_meta' ) ) {" + ); + } + + // Add placeholder_escape + wpdb polyfills (for WP < 4.8.3). + if ( + filePath.endsWith('class-wp-sqlite-db.php') && + !content.includes('function add_placeholder_escape') + ) { + const polyfill = ` + + public function placeholder_escape() { + static $placeholder; + if ( ! $placeholder ) { + $algo = function_exists( 'hash' ) ? 'sha256' : 'sha1'; + $salt = defined( 'AUTH_SALT' ) && AUTH_SALT ? AUTH_SALT : (string) rand(); + $placeholder = '{' . hash_hmac( $algo, uniqid( $salt, true ), $salt ) . '}'; + } + if ( function_exists( 'add_filter' ) + && function_exists( 'has_filter' ) + && false === has_filter( 'query', array( $this, 'remove_placeholder_escape' ) ) + ) { + add_filter( 'query', array( $this, 'remove_placeholder_escape' ), 0 ); + } + return $placeholder; + } + public function add_placeholder_escape( $query ) { + return str_replace( '%', $this->placeholder_escape(), $query ); + } + public function remove_placeholder_escape( $query ) { + return str_replace( $this->placeholder_escape(), '%', $query ); + } + public function get_caller() { + if ( method_exists( get_parent_class( $this ), 'get_caller' ) ) { + return parent::get_caller(); + } + return ''; + } + public function log_query( $query, $elapsed, $caller, $start = 0.0, $data = array() ) { + if ( method_exists( get_parent_class( $this ), 'log_query' ) ) { + return parent::log_query( $query, $elapsed, $caller, $start, $data ); + } + if ( !isset( $this->queries ) ) { $this->queries = array(); } + $this->queries[] = array( $query, $elapsed, $caller ); + } + + public $insert_id = 0; + public $num_rows = 0; + public $last_result = array(); + public $last_error = ''; + public $last_query = null; + public $rows_affected = 0; + public $col_info = null; + public $result = null; + public $incompatible_modes = array(); + public $dbname = null; + + public function reinitialize_sqlite() { + if ( $this->dbh instanceof WP_SQLite_Driver || $this->dbh instanceof WP_SQLite_Translator ) { + return; + } + if ( empty( $this->dbname ) && defined( 'DB_NAME' ) ) { + $this->dbname = DB_NAME; + } + if ( !isset( $this->last_result ) ) { + $this->last_result = array(); + } + global $table_prefix; + if ( isset( $table_prefix ) && empty( $this->prefix ) && method_exists( $this, 'set_prefix' ) ) { + $this->set_prefix( $table_prefix ); + } + if ( !isset( $GLOBALS['wp_queries'] ) ) { + $GLOBALS['wp_queries'] = ''; + } + $this->dbh = null; + $this->db_connect(); + } + public function init_charset() { + if ( method_exists( get_parent_class( $this ), 'init_charset' ) ) { + parent::init_charset(); + } elseif ( defined( 'DB_CHARSET' ) ) { + $this->charset = DB_CHARSET; + } + } +`; + // Find the WP_SQLite_DB class body's closing brace. The + // AST downgrader appends helper functions after the class, + // so lastIndexOf('}') would land inside a helper — walk + // the braces from the class declaration instead. + const classIdx = content.indexOf('class WP_SQLite_DB'); + if (classIdx === -1) { + throw new Error( + 'class WP_SQLite_DB not found in class-wp-sqlite-db.php' + ); + } + const classOpen = content.indexOf('{', classIdx); + const classClose = findMatchingBrace(content, classOpen); + content = + content.slice(0, classClose) + + polyfill + + content.slice(classClose); + } + + // Fix WP_SQLite_DB::prepare() ReflectionProperty access. The + // wpdb `allow_unsafe_unquoted_parameters` property only + // appeared in WP 6.2. Reflecting on an absent property throws + // ReflectionException — wrap in a try/catch. + content = content.replace( + /\$wpdb_allow_unsafe_unquoted_parameters = \$this->__get\(\s*'allow_unsafe_unquoted_parameters'\s*\);\s*\n\s*if \(\s*\$wpdb_allow_unsafe_unquoted_parameters !== \$this->allow_unsafe_unquoted_parameters\s*\) \{\s*\n\s*\$property = new ReflectionProperty\([^}]+\}/s, + "if ( method_exists( $this, '__get' ) ) {\n\t\t\ttry {\n\t\t\t\t$wpdb_allow_unsafe_unquoted_parameters = $this->__get( 'allow_unsafe_unquoted_parameters' );\n\t\t\t\tif ( $wpdb_allow_unsafe_unquoted_parameters !== $this->allow_unsafe_unquoted_parameters ) {\n\t\t\t\t\t$property = new ReflectionProperty( 'wpdb', 'allow_unsafe_unquoted_parameters' );\n\t\t\t\t\t$property->setAccessible( true );\n\t\t\t\t\t$property->setValue( $this, $this->allow_unsafe_unquoted_parameters );\n\t\t\t\t\t$property->setAccessible( false );\n\t\t\t\t}\n\t\t\t} catch (Exception $e) { /* Old WP lacks this property */ }\n\t\t\t}" + ); + + // PDO\SQLite / PDO\MySQL — namespace-qualified class references + // inside `instanceof` that PHP 5.2 can't even parse. Replace + // the whole instanceof with `false` (PHP 5.2 can never be PDO\X). + content = content.replace( + /\$\w+\s+instanceof\s+PDO\\(?:SQLite|MySQL)/g, + 'false' + ); + + // `WP_SQLite_Driver::__set('main_db_name', $value)` and + // `quote_mysql_utf8_string_literal()` use `Closure::call()` to + // access private members of the inner mysql_on_sqlite_driver. + // PHP 5.2 has no closures; the AST hoist produces a string + // helper name, so the original `$closure->call(...)` syntax + // becomes a "method call on non-object" fatal at runtime. + // + // Both proxies exist for tests only. The driver constructor + // fires `$this->main_db_name = $database` which triggers the + // magic `__set` because `main_db_name` is not declared on the + // driver class — that's the call path that crashes legacy WP + // boot. Declare a real public `$main_db_name` property on + // WP_SQLite_Driver so the assignment lands on a regular slot + // instead of going through `__set`, then neutralise both + // proxy bodies so they can never invoke the broken closures. + if (filePath.endsWith('class-wp-sqlite-driver.php')) { + // Add a real `public $main_db_name` property right after + // the class declaration. Anchored on the + // `mysql_on_sqlite_driver` proxy property which the + // downgrader leaves as the first declared field. + content = content.replace( + /(class WP_SQLite_Driver\b[^{]*\{)/, + '$1\n public $main_db_name = null;' + ); + // Replace the broken `__set` body for `main_db_name` + // with a direct field assignment. The remaining magic + // for other property names is fine (it still throws via + // the closure path, but no caller hits it during boot). + // Use [\s\S]*? (non-greedy any-including-newline) for the + // _pg52_set_capture(...) argument list because it contains + // nested parentheses (the captures `array(...)`). + content = content.replace( + /if \('main_db_name' === \$name\) \{\s*\$closure = _pg52_set_capture\([\s\S]*?\);\s*\$closure->call\(\$this->mysql_on_sqlite_driver, \$value\);\s*\}/, + `if ('main_db_name' === \$name) { + \$this->main_db_name = \$value; + // Best-effort: also set on the inner driver if it exposes + // the slot publicly. PHP 5.2 has no Closure::bind, so we + // can't reach private fields from here. + if (property_exists(\$this->mysql_on_sqlite_driver, 'main_db_name')) { + @\$this->mysql_on_sqlite_driver->main_db_name = \$value; + } + }` + ); + // Replace the broken `quote_mysql_utf8_string_literal` + // proxy body with a no-op fallback. It's only used by + // the same closure-based path which never works on PHP + // 5.2; the inner driver's own private copy is what + // production code actually uses. + content = content.replace( + /(private function quote_mysql_utf8_string_literal\(\$utf8_literal\)\s*\{)\s*\$closure = _pg52_set_capture\([\s\S]*?\);\s*return \$closure->call\(\$this->mysql_on_sqlite_driver, \$utf8_literal\);\s*\}/, + `$1 + // PHP 5.2: no Closure::call(); fall back to a naive escape. + // The inner driver's own private copy is what production code + // hits — this proxy only exists for tests. + return "'" . str_replace("'", "''", \$utf8_literal) . "'"; + }` + ); + } + + // Exception::__construct only takes 2 params in PHP 5.2 + // (PHP 5.3 added $previous). Strip the third arg when a + // subclass passes it to parent::__construct. + if (filePath.endsWith('class-wp-sqlite-driver-exception.php')) { + content = content.replace( + /parent::__construct\(\s*\$message,\s*0,\s*\$previous\s*\)/, + 'parent::__construct( $message, 0 )' + ); + } + + // Inline the PHP < 8 trait definitions from + // class-wp-pdo-proxy-statement.php. The upstream file ships a + // `if ( PHP_VERSION_ID < 80000 ) { trait ... } else { trait ... }` + // block with two identically-named traits. PHP 5.2 doesn't + // know about traits at all. Delete both blocks and replace + // `use TraitName;` with the inlined methods from the PHP < 8 + // branch. + if (filePath.endsWith('class-wp-pdo-proxy-statement.php')) { + content = inlineProxyStatementTraits(content); + } + + // Add the array_column() polyfill (PHP 5.5+) to each + // php-polyfills.php. The polyfills file runs early, so it + // covers every subsequent file. + if ( + filePath.endsWith('php-polyfills.php') && + !content.includes('function array_column') + ) { + content += ` +if ( ! function_exists( 'array_column' ) ) { +\t/** +\t * PHP 5.5+ array_column() polyfill for PHP 5.2. +\t */ +\tfunction array_column( $input, $column_key, $index_key = null ) { +\t\t$result = array(); +\t\tforeach ( $input as $row ) { +\t\t\t$has_value = false; +\t\t\t$value = null; +\t\t\tif ( null === $column_key ) { +\t\t\t\t$value = $row; +\t\t\t\t$has_value = true; +\t\t\t} elseif ( is_array( $row ) && array_key_exists( $column_key, $row ) ) { +\t\t\t\t$value = $row[ $column_key ]; +\t\t\t\t$has_value = true; +\t\t\t} elseif ( is_object( $row ) && isset( $row->{ $column_key } ) ) { +\t\t\t\t$value = $row->{ $column_key }; +\t\t\t\t$has_value = true; +\t\t\t} +\t\t\tif ( ! $has_value ) { +\t\t\t\tcontinue; +\t\t\t} +\t\t\tif ( null === $index_key ) { +\t\t\t\t$result[] = $value; +\t\t\t} else { +\t\t\t\t$key = null; +\t\t\t\tif ( is_array( $row ) && array_key_exists( $index_key, $row ) ) { +\t\t\t\t\t$key = $row[ $index_key ]; +\t\t\t\t} elseif ( is_object( $row ) && isset( $row->{ $index_key } ) ) { +\t\t\t\t\t$key = $row->{ $index_key }; +\t\t\t\t} +\t\t\t\tif ( null !== $key ) { +\t\t\t\t\t$result[ $key ] = $value; +\t\t\t\t} else { +\t\t\t\t\t$result[] = $value; +\t\t\t\t} +\t\t\t} +\t\t} +\t\treturn $result; +\t} +} +`; + } + + if (content !== original) { + fs.writeFileSync(filePath, content); + surgicalCount++; + console.log(` Surgical: ${rel}`); + } + } + console.log( + `Applied surgical fixes to ${surgicalCount}/${files.length} files` + ); + + // Static post-pass: scan the pretty-printed output for `self::`, + // `parent::`, or `static::` references that ended up OUTSIDE any + // class body. The PHP 5.2 SAPI lint catches parse errors but not + // these — they're a runtime fatal ("Cannot access self:: when no + // class scope is active") and would otherwise only surface during + // the legacy WP boot test. Failing here gives a precise file:line. + const scanResult = spawnSync( + 'node', + [ + path.join( + REPO_ROOT, + 'scripts/php52-downgrader/bin/scan-out-of-class-self.mjs' + ), + pluginRoot, + ], + { stdio: ['ignore', 'inherit', 'inherit'] } + ); + if (scanResult.status !== 0) { + throw new Error( + 'out-of-class self::/parent::/static:: scan failed; see diagnostics above' + ); + } + + // Re-zip. + if (fs.existsSync(OUT_ZIP)) fs.unlinkSync(OUT_ZIP); + execSync(`cd "${TMP_DIR}" && zip -r -q "${OUT_ZIP}" .`); + console.log(`\nCreated: ${OUT_ZIP}`); +} finally { + fs.rmSync(TMP_DIR, { recursive: true, force: true }); +} + +/** Returns the index of the `}` matching the `{` at `openIdx`, or -1. */ +function findMatchingBrace(str, openIdx) { + let depth = 0; + let inString = null; + for (let i = openIdx; i < str.length; i++) { + const ch = str[i]; + if (inString) { + if (ch === '\\') { + i++; + continue; + } + if (ch === inString) inString = null; + continue; + } + if (ch === "'" || ch === '"') { + inString = ch; + continue; + } + if (ch === '/' && str[i + 1] === '/') { + const nl = str.indexOf('\n', i); + i = nl === -1 ? str.length : nl; + continue; + } + if (ch === '/' && str[i + 1] === '*') { + const end = str.indexOf('*/', i + 2); + i = end === -1 ? str.length : end + 1; + continue; + } + if (ch === '{') { + depth++; + } else if (ch === '}') { + depth--; + if (depth === 0) return i; + } + } + return -1; +} + +function findFiles(dir) { + const out = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) out.push(...findFiles(full)); + else if (entry.name.endsWith('.php') || entry.name.endsWith('.copy')) + out.push(full); + } + return out; +} + +/** + * Inlines the PHP < 8 trait methods of class-wp-pdo-proxy-statement.php + * directly into the class body. PHP 5.2 doesn't know about traits. + */ +function inlineProxyStatementTraits(content) { + // Remove the entire `if ( PHP_VERSION_ID < 80000 ) { trait ... } else { trait ... }` block. + content = content.replace( + /if \(PHP_VERSION_ID < 80000\) \{[\s\S]*?\n\} else \{[\s\S]*?\n\}\n/, + '' + ); + // Replace `use WP_PDO_Proxy_Statement_PHP_Compat;` with the + // inlined PHP 5.2-compatible methods. + const inlined = ` + public function setFetchMode( $mode, $params = null ) { + if ( null === $params ) { + return $this->setDefaultFetchMode( $mode ); + } + return $this->setDefaultFetchMode( $mode, $params ); + } + + public function fetchAll( $mode = null, $class_name = null, $constructor_args = null ) { + if ( null === $class_name && null === $constructor_args ) { + return $this->fetchAllRows( $mode ); + } + return $this->fetchAllRows( $mode, $class_name, $constructor_args ); + } +`; + content = content.replace( + /^\s*use WP_PDO_Proxy_Statement_PHP_Compat;/m, + inlined + ); + return content; +} diff --git a/scripts/php52-downgrader/.gitignore b/scripts/php52-downgrader/.gitignore new file mode 100644 index 00000000000..57872d0f1e5 --- /dev/null +++ b/scripts/php52-downgrader/.gitignore @@ -0,0 +1 @@ +/vendor/ diff --git a/scripts/php52-downgrader/bin/downgrade.php b/scripts/php52-downgrader/bin/downgrade.php new file mode 100644 index 00000000000..a0b983f80d6 --- /dev/null +++ b/scripts/php52-downgrader/bin/downgrade.php @@ -0,0 +1,123 @@ +#!/usr/bin/env php + PHP 5.2 downgrader entrypoint. + * + * Walks every `.php` (and `.copy`) file under the given directory and + * rewrites it in place using an AST-based pipeline built on top of + * nikic/php-parser v5. + * + * Usage: + * php scripts/php52-downgrader/bin/downgrade.php [--output=] + * + * Exits with a non-zero status if any file fails to parse, transform, + * or pretty-print. + */ + +declare(strict_types=1); + +require __DIR__ . '/../vendor/autoload.php'; + +use WpPlayground\Php52Downgrader\Downgrader; + +$argv = $_SERVER['argv']; +array_shift($argv); + +$inputDir = null; +$outputDir = null; +foreach ($argv as $arg) { + if (strpos($arg, '--output=') === 0) { + $outputDir = substr($arg, strlen('--output=')); + } elseif ($inputDir === null) { + $inputDir = $arg; + } +} + +if ($inputDir === null) { + fwrite(STDERR, "usage: downgrade.php [--output=]\n"); + exit(2); +} +if (!is_dir($inputDir)) { + fwrite(STDERR, "error: input directory not found: {$inputDir}\n"); + exit(2); +} + +$downgrader = new Downgrader(); + +$failures = []; +$patched = 0; +$total = 0; + +// Collect every file path up front so we can run two passes in a +// stable order. Pass 1 = discovery (which class constants will be +// promoted to static props anywhere in the project); pass 2 = full +// downgrade with the cross-file registry available. +$allFiles = []; +$iter = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($inputDir, FilesystemIterator::SKIP_DOTS) +); +foreach ($iter as $fileInfo) { + /** @var SplFileInfo $fileInfo */ + if (!$fileInfo->isFile()) { + continue; + } + $name = $fileInfo->getFilename(); + if (!preg_match('/\.(php|copy)$/', $name)) { + continue; + } + $allFiles[] = $fileInfo->getPathname(); +} +sort($allFiles); + +// Pass 1: cross-file hoisted-constant discovery. Walks the AST with +// only the namespace-strip + ArrayClassConstantVisitor so the +// downgrader knows every `Foo::CONST` reference that file B will +// need rewritten to `Foo::$CONST` (where `Foo` was promoted in file +// A). Skipped files (parse errors etc.) propagate to pass 2 so the +// developer sees the error there. +foreach ($allFiles as $path) { + $rel = ltrim(substr($path, strlen($inputDir)), '/'); + $source = @file_get_contents($path); + if ($source === false) { + continue; + } + try { + $downgrader->collectHoistedConsts($source); + } catch (Throwable $e) { + // Defer the error to pass 2 so the file shows up in the + // failures list with a real downgrade-context message. + } +} + +foreach ($allFiles as $path) { + $total++; + $rel = ltrim(substr($path, strlen($inputDir)), '/'); + try { + $source = file_get_contents($path); + if ($source === false) { + throw new RuntimeException("unreadable file"); + } + $result = $downgrader->downgrade($source, $rel); + if ($outputDir !== null) { + $destPath = rtrim($outputDir, '/') . '/' . $rel; + @mkdir(dirname($destPath), 0777, true); + file_put_contents($destPath, $result); + } else { + if ($result !== $source) { + file_put_contents($path, $result); + $patched++; + } + } + } catch (Throwable $e) { + $failures[] = "{$rel}: " . $e->getMessage(); + fwrite(STDERR, "FAIL {$rel}: " . $e->getMessage() . "\n"); + } +} + +fwrite(STDOUT, "downgraded {$patched}/{$total} files\n"); + +if ($failures) { + fwrite(STDERR, "\n" . count($failures) . " file(s) failed.\n"); + exit(1); +} +exit(0); diff --git a/scripts/php52-downgrader/bin/lint-php52.mjs b/scripts/php52-downgrader/bin/lint-php52.mjs new file mode 100644 index 00000000000..db520c62ab4 --- /dev/null +++ b/scripts/php52-downgrader/bin/lint-php52.mjs @@ -0,0 +1,100 @@ +/** + * Smoke-tests every `.php`/`.copy` file under a directory with the + * PHP 5.2 WebAssembly runtime by loading the file through the SAPI + * request handler and inspecting stderr for parse errors. + * + * Usage: node scripts/php52-downgrader/bin/lint-php52.mjs + * + * Prints a summary and exits non-zero if any file has a Parse error. + * Non-parse runtime errors (undefined functions, missing classes, + * includes failing, etc.) are ignored — the files are executed in a + * context that doesn't have WordPress or the rest of the plugin + * loaded, so runtime failures are expected and uninteresting for the + * purpose of syntax validation. + * + * The PHP 5.2 WASM build doesn't expose a CLI entry point + * (`wasm_add_cli_arg` / `run_cli` aren't compiled in), so we can't + * use `php.cli(['php', '-l', ...])`. The SAPI `run()` path is the + * only supported invocation — PHP compiles the whole file before + * execution begins, and a parse error shows up in stderr regardless + * of what the first statement tries to do. + */ +import fs from 'fs'; +import path from 'path'; +import { loadNodeRuntime } from '@php-wasm/node'; +import { PHP, FileLockManagerInMemory } from '@php-wasm/universal'; + +const ROOT = process.argv[2]; +if (!ROOT) { + console.error('usage: lint-php52.mjs '); + process.exit(2); +} + +function findFiles(dir) { + const out = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...findFiles(full)); + } else if (/\.(php|copy)$/.test(entry.name)) { + out.push(full); + } + } + return out; +} + +const files = findFiles(ROOT); +console.log(`Linting ${files.length} files with PHP 5.2 WASM...`); + +// A single PHP instance is reusable across run() calls (unlike cli()), +// and PHP 5.2 WASM doesn't expose cli() anyway, so we just use one +// instance for the whole lint. +const php = new PHP( + await loadNodeRuntime('5.2', { + fileLockManager: new FileLockManagerInMemory(), + emscriptenOptions: { processId: 1 }, + }) +); +php.mkdir('/check'); + +let failures = 0; +for (const file of files) { + const rel = path.relative(ROOT, file); + try { + const contents = fs.readFileSync(file, 'utf-8'); + php.writeFile('/check/file.php', contents); + let response; + try { + response = await php.run({ scriptPath: '/check/file.php' }); + } catch (err) { + // run() throws PHPExecutionFailureError on any non-zero + // exit code. Fish out the inner response so we can inspect + // stderr for parse errors. Anything that isn't a parse + // error is not our concern for lint purposes. + if (err && err.response) { + response = err.response; + } else { + throw err; + } + } + const stderr = response.errors || ''; + const stdout = response.text || ''; + const combined = stderr + '\n' + stdout; + if (/Parse error|syntax error/i.test(combined)) { + failures++; + console.log(`FAIL ${rel}`); + const firstErr = combined + .split('\n') + .find((l) => /Parse error|syntax error/i.test(l)); + if (firstErr) { + console.log(` ${firstErr.trim()}`); + } + } + } catch (e) { + failures++; + console.log(`FAIL ${rel}: ${e.message}`); + } +} + +console.log(`\n${failures}/${files.length} failed`); +process.exit(failures ? 1 : 0); diff --git a/scripts/php52-downgrader/bin/scan-out-of-class-self.mjs b/scripts/php52-downgrader/bin/scan-out-of-class-self.mjs new file mode 100644 index 00000000000..990167eb303 --- /dev/null +++ b/scripts/php52-downgrader/bin/scan-out-of-class-self.mjs @@ -0,0 +1,311 @@ +/** + * Static scan for `self::`, `parent::`, and `static::` tokens that + * appear OUTSIDE any class body in pretty-printed PHP source. + * + * PHP 5.2 (and every other PHP) treats those as a runtime fatal: + * + * Fatal error: Cannot access self:: when no class scope is active + * + * The PHP 5.2 SAPI lint (`lint-php52.mjs`) only catches PARSE errors, + * so this class of bug slips past it. The legacy WP boot test does + * catch it, but only after a full WordPress request cycle. This scan + * is a fast, deterministic check we can run right after the AST + * downgrader to fail the build with a precise file:line. + * + * Algorithm: walk every `.php`/`.copy` file under the given directory + * one character at a time, tracking: + * + * - whether we are inside a `'`/`"` string literal (with backslash + * escapes) or a heredoc/nowdoc; + * - whether we are inside a `//`, `#`, or `/* … *\/` comment; + * - the brace depth at which the most recent `class`/`interface`/ + * `trait` keyword was opened, so we know if the current `{`-level + * is still inside that class body. + * + * Whenever we encounter `self::`, `parent::`, or `static::` outside any + * class body, record a violation with file path and line number. + * + * This is intentionally a token scan, not an AST walk: the input has + * already been pretty-printed and re-tokenised would be 100x slower. + * The simple state machine catches every violation we have seen in + * practice, including refs inside hoisted top-level array literals, + * top-level function bodies, and global expressions. + * + * Usage: node scripts/php52-downgrader/bin/scan-out-of-class-self.mjs + * + * Exits non-zero (with a diagnostic) on any violation. + */ +import fs from 'fs'; +import path from 'path'; + +const ROOT = process.argv[2]; +if (!ROOT) { + console.error('usage: scan-out-of-class-self.mjs '); + process.exit(2); +} + +function findFiles(dir) { + const out = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...findFiles(full)); + } else if (/\.(php|copy)$/.test(entry.name)) { + out.push(full); + } + } + return out; +} + +const files = findFiles(ROOT); + +const violations = []; +for (const file of files) { + const src = fs.readFileSync(file, 'utf-8'); + scanOne(file, src); +} + +if (violations.length > 0) { + console.error( + `\nFAIL: ${violations.length} out-of-class self::/parent::/static:: violations\n` + ); + for (const v of violations) { + console.error(` ${v.file}:${v.line}: ${v.snippet}`); + } + process.exit(1); +} +console.log(`scanned ${files.length} files, 0 out-of-class self:: violations`); + +/** + * Scans a single file. Pushes any out-of-class self/parent/static + * scope-resolution into the shared `violations` array. + * + * State: brace depth and a stack of "class-opens" recording the brace + * depth at which each currently-open class/interface/trait body began. + * When we see `{` at depth N right after a class declaration, we push + * N+1 onto the stack and increment depth. When `}` closes that depth, + * we pop. The stack is non-empty iff we are inside a class body. + * + * Implementation note: PHP 5.2 has no traits, but the downgraded + * output may still contain `interface` declarations, so we treat + * interface/trait bodies as class scopes too. + */ +function scanOne(filePath, src) { + const len = src.length; + let i = 0; + let line = 1; + let inPhp = false; + let braceDepth = 0; + const classOpenDepths = []; + let pendingClassDecl = false; + + while (i < len) { + const ch = src[i]; + const next = src[i + 1]; + + if (ch === '\n') line++; + + if (!inPhp) { + // Look for `') { + inPhp = false; + i += 2; + continue; + } + + // Line comment. + if (ch === '/' && next === '/') { + const nl = src.indexOf('\n', i); + if (nl === -1) return; + i = nl; + continue; + } + // `#` line comment. + if (ch === '#') { + const nl = src.indexOf('\n', i); + if (nl === -1) return; + i = nl; + continue; + } + // Block comment. + if (ch === '/' && next === '*') { + const end = src.indexOf('*/', i + 2); + if (end === -1) return; + for (let k = i; k < end + 2; k++) { + if (src[k] === '\n') line++; + } + i = end + 2; + continue; + } + + // Strings. + if (ch === "'") { + i++; + while (i < len) { + if (src[i] === '\\' && src[i + 1] !== undefined) { + i += 2; + continue; + } + if (src[i] === '\n') line++; + if (src[i] === "'") { + i++; + break; + } + i++; + } + continue; + } + if (ch === '"') { + i++; + while (i < len) { + if (src[i] === '\\' && src[i + 1] !== undefined) { + i += 2; + continue; + } + if (src[i] === '\n') line++; + if (src[i] === '"') { + i++; + break; + } + i++; + } + continue; + } + // Heredoc / nowdoc. + if (ch === '<' && next === '<' && src[i + 2] === '<') { + // `<< 0 && + classOpenDepths[classOpenDepths.length - 1] === braceDepth + ) { + classOpenDepths.pop(); + } + braceDepth--; + i++; + continue; + } + // `;` cancels a pending class decl (e.g. `class Foo;` doesn't exist + // but `use Foo, Bar;` after an unrelated `class` keyword shouldn't + // confuse the state). + if (ch === ';' && pendingClassDecl) { + pendingClassDecl = false; + } + + // Class / interface / trait keyword. + if ( + (ch === 'c' || ch === 'i' || ch === 't') && + isWordBoundary(src, i) + ) { + const rest = src.slice(i, i + 10); + let kwLen = 0; + if (/^class\b/.test(rest) && !isClassConstantUse(src, i)) { + kwLen = 5; + } else if (/^interface\b/.test(rest)) { + kwLen = 9; + } else if (/^trait\b/.test(rest)) { + kwLen = 5; + } + if (kwLen > 0) { + pendingClassDecl = true; + i += kwLen; + continue; + } + } + + // The actual scan: self::, parent::, static::. + if ((ch === 's' || ch === 'p') && isWordBoundary(src, i)) { + const m = /^(self|parent|static)::/.exec(src.slice(i)); + if (m) { + if (classOpenDepths.length === 0) { + const lineSnippet = extractLine(src, i).trim(); + violations.push({ + file: path.relative(process.cwd(), filePath), + line, + snippet: lineSnippet.slice(0, 200), + }); + } + i += m[0].length; + continue; + } + } + + i++; + } +} + +/** + * True if position `i` is at a word boundary (the previous character is + * not part of an identifier). + */ +function isWordBoundary(src, i) { + if (i === 0) return true; + const prev = src[i - 1]; + return !/[A-Za-z0-9_$\\]/.test(prev); +} + +/** + * Distinguishes the `class` keyword as a class declaration from + * `Foo::class` (PHP 5.5+ class-name fetch). The downgrader rewrites + * `::class` away, but be defensive in case any survive. + */ +function isClassConstantUse(src, i) { + if (i < 2) return false; + return src[i - 1] === ':' && src[i - 2] === ':'; +} + +function extractLine(src, idx) { + const start = src.lastIndexOf('\n', idx - 1) + 1; + let end = src.indexOf('\n', idx); + if (end === -1) end = src.length; + return src.slice(start, end); +} diff --git a/scripts/php52-downgrader/composer.json b/scripts/php52-downgrader/composer.json new file mode 100644 index 00000000000..1e038145fc5 --- /dev/null +++ b/scripts/php52-downgrader/composer.json @@ -0,0 +1,19 @@ +{ + "name": "wp-playground/php52-downgrader", + "description": "AST-based PHP 7+ -> PHP 5.2 downgrader for the offline SQLite integration plugin patcher.", + "type": "library", + "license": "GPL-2.0-or-later", + "require": { + "php": ">=7.4", + "nikic/php-parser": "^5.0" + }, + "autoload": { + "psr-4": { + "WpPlayground\\Php52Downgrader\\": "src/" + } + }, + "config": { + "optimize-autoloader": true, + "sort-packages": true + } +} diff --git a/scripts/php52-downgrader/composer.lock b/scripts/php52-downgrader/composer.lock new file mode 100644 index 00000000000..402f130f9db --- /dev/null +++ b/scripts/php52-downgrader/composer.lock @@ -0,0 +1,79 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "871af23bd940813edbc0506ebe9005f0", + "packages": [ + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=7.4" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/scripts/php52-downgrader/src/Downgrader.php b/scripts/php52-downgrader/src/Downgrader.php new file mode 100644 index 00000000000..fa6c661d8c8 --- /dev/null +++ b/scripts/php52-downgrader/src/Downgrader.php @@ -0,0 +1,199 @@ + PHP 5.2 transformation pipeline. + * + * One instance can be reused for multiple files. Each call to + * {@see downgrade()} parses the source with nikic/php-parser, runs a + * chain of {@see PhpParser\NodeVisitorAbstract} transformations, and + * pretty-prints the result with {@see PrettyPrinter} (a subclass of + * PhpParser\PrettyPrinter\Standard that overrides the few nodes where + * the upstream printer emits PHP 5.3+ syntax). + */ + +declare(strict_types=1); + +namespace WpPlayground\Php52Downgrader; + +use PhpParser\NodeTraverser; +use PhpParser\NodeVisitor\NameResolver; +use PhpParser\NodeVisitor\ParentConnectingVisitor; +use PhpParser\ParserFactory; +use PhpParser\PhpVersion; +use WpPlayground\Php52Downgrader\Visitor\ArrayClassConstantVisitor; +use WpPlayground\Php52Downgrader\Visitor\ArrayDerefOnCallVisitor; +use WpPlayground\Php52Downgrader\Visitor\AttributeAndDeclareStripVisitor; +use WpPlayground\Php52Downgrader\Visitor\CallableExprVisitor; +use WpPlayground\Php52Downgrader\Visitor\ClassKeywordVisitor; +use WpPlayground\Php52Downgrader\Visitor\ClosureHoistingVisitor; +use WpPlayground\Php52Downgrader\Visitor\DirConstantVisitor; +use WpPlayground\Php52Downgrader\Visitor\ExponentVisitor; +use WpPlayground\Php52Downgrader\Visitor\FinallyVisitor; +use WpPlayground\Php52Downgrader\Visitor\InstanceCallOnNewVisitor; +use WpPlayground\Php52Downgrader\Visitor\LateStaticBindingVisitor; +use WpPlayground\Php52Downgrader\Visitor\NamespaceStripVisitor; +use WpPlayground\Php52Downgrader\Visitor\NullCoalescingVisitor; +use WpPlayground\Php52Downgrader\Visitor\NullsafeVisitor; +use WpPlayground\Php52Downgrader\Visitor\Php7ErrorClassesVisitor; +use WpPlayground\Php52Downgrader\Visitor\PromoteForHoistedClosuresVisitor; +use WpPlayground\Php52Downgrader\Visitor\ReservedMethodRenameVisitor; +use WpPlayground\Php52Downgrader\Visitor\ShortTernaryVisitor; +use WpPlayground\Php52Downgrader\Visitor\StripTypeDeclarationsVisitor; +use WpPlayground\Php52Downgrader\Visitor\VariadicAndSplatVisitor; + +class Downgrader +{ + /** @var \PhpParser\Parser */ + private $parser; + + /** @var PrettyPrinter */ + private $printer; + + /** + * Cross-file map of every class constant that has been promoted + * to a static property. Populated by {@see collectHoistedConsts()} + * (pass 1 — discovery only) and consumed by {@see downgrade()} via + * the {@see ArrayClassConstantVisitor} (pass 2 — full rewrite, + * including cross-file references). + * + * Keys are `"ClassName::CONST_NAME"` strings; values are always + * `true`. Empty by default — single-file callers see the previous + * in-file-only behaviour. + * + * @var array + */ + private array $globalHoistedConsts = []; + + public function __construct() + { + $factory = new ParserFactory(); + // Parse as modern PHP so we accept every source feature. + $this->parser = $factory->createForVersion(PhpVersion::fromComponents(8, 3)); + $this->printer = new PrettyPrinter(); + } + + /** + * Pass 1: discovers every class constant that {@see ArrayClassConstantVisitor} + * would promote to a static property in `$source`. The discovery + * runs the same visitor against a throwaway AST so the rules + * stay in lockstep with the actual rewrite. Side-effects: + * accumulates entries into `$this->globalHoistedConsts`. + * + * Call this for every file in the project BEFORE calling + * {@see downgrade()} on any of them, so that downgrade() can use + * the complete cross-file map to rewrite references like + * `OtherClass::SOME_CONST` (where `OtherClass` lives in a + * different file) into the matching `OtherClass::$SOME_CONST` + * static property fetch. + * + * @param string $source Original file contents (with opening tag). + */ + public function collectHoistedConsts(string $source): void + { + $ast = $this->parser->parse($source); + if ($ast === null) { + throw new \RuntimeException('parser returned null'); + } + // We need NameResolver here too so any namespaced classes get + // the same `Foo\Bar` keys that downgrade() will emit. + $pre = new NodeTraverser(); + $pre->addVisitor(new NameResolver(null, ['replaceNodes' => true])); + $ast = $pre->traverse($ast); + + // Run only the namespace-strip visitor before discovery so the + // recorded class names are unqualified, matching downgrade() + // output. Other passes are not needed for discovery. + $ns = new NodeTraverser(); + $ns->addVisitor(new NamespaceStripVisitor()); + $ast = $ns->traverse($ast); + + $discovery = new ArrayClassConstantVisitor(); + $t = new NodeTraverser(); + $t->addVisitor($discovery); + $t->traverse($ast); + foreach ($discovery->getHoisted() as $key => $_) { + $this->globalHoistedConsts[$key] = true; + } + } + + /** + * Returns the discovered cross-file hoist registry. Useful for + * tests and orchestration code that needs to inspect the result + * of {@see collectHoistedConsts()}. + * + * @return array + */ + public function getGlobalHoistedConsts(): array + { + return $this->globalHoistedConsts; + } + + /** + * Runs the downgrade pipeline against a single file's source. + * + * If {@see collectHoistedConsts()} was called for every file + * beforehand, every cross-file `Foo::CONST` reference that points + * to a hoisted constant will be rewritten to `Foo::$CONST`. + * Otherwise the pass falls back to in-file-only rewriting. + * + * @param string $source Original file contents (with opening tag). + * @param string $relPath Path relative to the input root. Used to + * derive deterministic closure helper names. + */ + public function downgrade(string $source, string $relPath): string + { + $ast = $this->parser->parse($source); + if ($ast === null) { + throw new \RuntimeException('parser returned null'); + } + + // Resolve names so that `use Foo\Bar` aliases and namespace + // references are flattened before we rewrite them. The upstream + // SQLite plugin has no namespaces, but NameResolver also makes + // downstream visitors simpler because they see full Name nodes. + $preTraverser = new NodeTraverser(); + $preTraverser->addVisitor(new NameResolver(null, ['replaceNodes' => true])); + $ast = $preTraverser->traverse($ast); + + // Run each transform in a dedicated traversal so visitors can't + // interfere with each other's parent/state tracking. + $passes = [ + [new AttributeAndDeclareStripVisitor()], + [new NamespaceStripVisitor()], + [new StripTypeDeclarationsVisitor()], + [new Php7ErrorClassesVisitor()], + [new NullsafeVisitor()], + [new NullCoalescingVisitor()], + [new VariadicAndSplatVisitor()], + [new ExponentVisitor()], + [new ClassKeywordVisitor()], + [new FinallyVisitor()], + [new InstanceCallOnNewVisitor()], + [new ArrayDerefOnCallVisitor()], + [new CallableExprVisitor()], + [new LateStaticBindingVisitor()], + [new ShortTernaryVisitor()], + [new DirConstantVisitor()], + [new ArrayClassConstantVisitor($this->globalHoistedConsts)], + [new ReservedMethodRenameVisitor()], + // Promote private/protected → public on classes whose + // bodies contain `$this`-using closures. Must run BEFORE + // the closure hoister so the promoted flags survive into + // the printed output, and AFTER every other transform so + // it observes the final closure shape. + [new PromoteForHoistedClosuresVisitor()], + // Closure hoisting has to run last because most other + // visitors rewrite subtrees that might contain closures. + [new ClosureHoistingVisitor($relPath)], + ]; + + foreach ($passes as $visitors) { + $t = new NodeTraverser(); + foreach ($visitors as $v) { + $t->addVisitor($v); + } + $ast = $t->traverse($ast); + } + + return $this->printer->prettyPrintFile($ast) . "\n"; + } +} diff --git a/scripts/php52-downgrader/src/PrettyPrinter.php b/scripts/php52-downgrader/src/PrettyPrinter.php new file mode 100644 index 00000000000..c7d73956dda --- /dev/null +++ b/scripts/php52-downgrader/src/PrettyPrinter.php @@ -0,0 +1,53 @@ + PhpVersion::fromComponents(7, 0), + 'shortArraySyntax' => false, + ]); + } + + protected function pExpr_Array(Expr\Array_ $node): string + { + // The parser tags the array with its source-text kind + // (SHORT vs LONG). The Standard printer honors that attribute + // instead of the `shortArraySyntax` constructor option, so an + // input `[]` stays `[]` on output. Force LONG syntax here. + $items = $this->pMaybeMultiline($node->items, true); + return 'array(' . $items . ')'; + } + + protected function pScalar_MagicConst_Dir(MagicConst\Dir $node): string + { + return 'dirname(__FILE__)'; + } +} diff --git a/scripts/php52-downgrader/src/Visitor/ArrayClassConstantVisitor.php b/scripts/php52-downgrader/src/Visitor/ArrayClassConstantVisitor.php new file mode 100644 index 00000000000..124236cadf9 --- /dev/null +++ b/scripts/php52-downgrader/src/Visitor/ArrayClassConstantVisitor.php @@ -0,0 +1,300 @@ +; // after the class, at top level + * + * and rewrite every `self::NAME` / `static::NAME` / `ClassName::NAME` + * reference in the file to the matching static property fetch. + */ + +declare(strict_types=1); + +namespace WpPlayground\Php52Downgrader\Visitor; + +use PhpParser\Modifiers; +use PhpParser\Node; +use PhpParser\Node\Expr; +use PhpParser\Node\Stmt; +use PhpParser\NodeTraverser; +use PhpParser\NodeVisitorAbstract; + +class ArrayClassConstantVisitor extends NodeVisitorAbstract +{ + /** + * Map "ClassName::CONST_NAME" => true of every constant we've + * promoted to a static property in THIS file so far. + * + * @var array + */ + private array $hoisted = []; + + /** + * Map "ClassName::CONST_NAME" => true populated externally with + * EVERY constant that will be promoted across all files in the + * project. Allows cross-file references (e.g. user code in file + * A reading `Foo::BAR` where `Foo::BAR` was promoted in file B) + * to be rewritten to the matching `Foo::$BAR` static property + * fetch. Defaults to empty for backwards compatibility — single + * file callers see the previous in-file-only behaviour. + * + * @var array + */ + private array $globalHoisted; + + /** + * @param array $globalHoisted External registry of + * every constant known to be promoted across all files. + * See {@see $globalHoisted} for usage. + */ + public function __construct(array $globalHoisted = []) + { + $this->globalHoisted = $globalHoisted; + } + + public function beforeTraverse(array $nodes) + { + $this->hoisted = []; + $this->processStatementList($nodes); + return $nodes; + } + + /** + * Returns the per-file map of constants this pass promoted from + * `const FOO = ...;` to `public static $FOO`. Used by the + * orchestrator to build {@see $globalHoisted} for a second pass. + * + * @return array + */ + public function getHoisted(): array + { + return $this->hoisted; + } + + public function afterTraverse(array $nodes) + { + // Merge in-file hoists (which we always know about, even on + // pass 1) with externally-supplied cross-file hoists. The + // second pass populates `$globalHoisted` with the union of + // every file's promotions; on the first pass it's empty and + // we still need to rewrite in-file references so the file + // itself stays consistent. + $lookup = $this->hoisted + $this->globalHoisted; + if (empty($lookup)) { + return null; + } + // Second pass: rewrite every matching ClassConstFetch to a + // StaticPropertyFetch using a fresh traverser. + $rewriter = new class ($lookup) extends NodeVisitorAbstract { + /** @var array */ + private array $hoisted; + /** @var string|null */ + private ?string $currentClass = null; + /** @var string|null */ + private ?string $currentParent = null; + + public function __construct(array $hoisted) + { + $this->hoisted = $hoisted; + } + + public function enterNode(Node $node) + { + if ($node instanceof Stmt\Class_) { + $this->currentClass = $node->name !== null ? $node->name->toString() : null; + $this->currentParent = $node->extends !== null ? $node->extends->toString() : null; + } + return null; + } + + public function leaveNode(Node $node) + { + if ($node instanceof Stmt\Class_) { + $this->currentClass = null; + $this->currentParent = null; + } + if ( + !$node instanceof Expr\ClassConstFetch + || !$node->name instanceof Node\Identifier + || !$node->class instanceof Node\Name + ) { + return null; + } + $cls = $node->class->toString(); + $name = $node->name->toString(); + $resolved = $cls; + if ($cls === 'self' || $cls === 'static') { + $resolved = $this->currentClass ?? $cls; + } elseif ($cls === 'parent') { + $resolved = $this->currentParent ?? $cls; + } + if (!isset($this->hoisted[$resolved . '::' . $name])) { + return null; + } + return new Expr\StaticPropertyFetch( + new Node\Name($cls === 'static' ? 'self' : $cls), + new Node\VarLikeIdentifier($name), + $node->getAttributes() + ); + } + }; + $t = new NodeTraverser(); + $t->addVisitor($rewriter); + return $t->traverse($nodes); + } + + /** + * Walks a statement list and rewrites any class declarations in it + * that contain non-constant-expression class constants. Recurses + * into blocks (if, while, namespace wrappers, etc.). + * + * @param array $stmts passed by reference to splice in + * the static initializer blocks. + */ + private function processStatementList(array &$stmts): void + { + for ($i = 0; $i < count($stmts); $i++) { + $stmt = $stmts[$i]; + if ($stmt instanceof Stmt\Class_) { + $initializers = $this->rewriteClass($stmt); + if ($initializers) { + array_splice($stmts, $i + 1, 0, $initializers); + $i += count($initializers); + } + continue; + } + // Recurse into nested statement lists. + foreach (['stmts', 'cases', 'catches', 'finally'] as $field) { + if (isset($stmt->{$field}) && is_array($stmt->{$field})) { + $list = $stmt->{$field}; + $this->processStatementList($list); + $stmt->{$field} = $list; + } + } + if (isset($stmt->else) && $stmt->else !== null) { + $list = $stmt->else->stmts; + $this->processStatementList($list); + $stmt->else->stmts = $list; + } + if (isset($stmt->elseifs) && is_array($stmt->elseifs)) { + foreach ($stmt->elseifs as $elseif) { + $list = $elseif->stmts; + $this->processStatementList($list); + $elseif->stmts = $list; + } + } + } + } + + /** + * Rewrites a single class declaration in place and returns the + * list of static initializer statements to inject after it. + * + * @return array + */ + private function rewriteClass(Stmt\Class_ $class): array + { + $className = $class->name?->toString(); + if ($className === null) { + return []; + } + $initializers = []; + $newStmts = []; + foreach ($class->stmts as $stmt) { + if (!$stmt instanceof Stmt\ClassConst) { + $newStmts[] = $stmt; + continue; + } + $kept = []; + $hoistedConsts = []; + foreach ($stmt->consts as $const) { + if ($this->isPhp52ConstantExpr($const->value)) { + $kept[] = $const; + continue; + } + $hoistedConsts[] = $const; + } + if ($kept) { + $splitStmt = clone $stmt; + $splitStmt->consts = $kept; + $newStmts[] = $splitStmt; + } + $parentName = $class->extends?->toString(); + foreach ($hoistedConsts as $const) { + $this->hoisted[$className . '::' . $const->name->name] = true; + $newStmts[] = new Stmt\Property( + Modifiers::PUBLIC | Modifiers::STATIC, + [ + new Node\PropertyItem( + new Node\VarLikeIdentifier($const->name->name), + new Expr\ConstFetch(new Node\Name('null')) + ), + ] + ); + // CRITICAL: the value is about to be moved OUT of the + // class body to top-level scope, where `self::`, + // `parent::`, and `static::` are no longer valid. + // Rewrite every such reference to the literal class + // name before emitting the initializer. + $rewrittenValue = SelfParentStaticRewriter::rewriteInExpr( + $const->value, + $className, + $parentName, + 'extracted expression' + ); + $initializers[] = new Stmt\Expression( + new Expr\Assign( + new Expr\StaticPropertyFetch( + new Node\Name($className), + new Node\VarLikeIdentifier($const->name->name) + ), + $rewrittenValue + ) + ); + } + } + $class->stmts = $newStmts; + return $initializers; + } + + /** + * Returns true when the expression is a PHP 5.2 compile-time + * constant expression (scalar literal, true/false/null, + * or a negated/plussed literal). + */ + private function isPhp52ConstantExpr(Node\Expr $expr): bool + { + if ($expr instanceof Node\Scalar\Int_) { + return true; + } + if ($expr instanceof Node\Scalar\Float_) { + return true; + } + if ($expr instanceof Node\Scalar\String_) { + return true; + } + if ($expr instanceof Expr\ConstFetch) { + $name = $expr->name->toString(); + return in_array( + $name, + ['true', 'false', 'null', 'TRUE', 'FALSE', 'NULL'], + true + ); + } + if ($expr instanceof Expr\UnaryMinus || $expr instanceof Expr\UnaryPlus) { + return $this->isPhp52ConstantExpr($expr->expr); + } + return false; + } +} diff --git a/scripts/php52-downgrader/src/Visitor/ArrayDerefOnCallVisitor.php b/scripts/php52-downgrader/src/Visitor/ArrayDerefOnCallVisitor.php new file mode 100644 index 00000000000..4c072a863f5 --- /dev/null +++ b/scripts/php52-downgrader/src/Visitor/ArrayDerefOnCallVisitor.php @@ -0,0 +1,61 @@ + _pg52_at(fn(), 0) + * $obj->method()['x'] => _pg52_at($obj->method(), 'x') + * + * PHP 5.4 added direct array dereferencing of call expressions; + * PHP 5.2 requires the intermediate value to be assigned to a + * variable before it can be indexed. We route it through a helper + * function `_pg52_at($arr, $idx)` that returns the element or `null`. + * + * The helper is emitted once per file that uses it by the Closure + * hoisting visitor's trailing helper block. (In practice we emit it + * unconditionally as a guarded `function_exists` wrapper so files + * that don't use it still get the definition — cheap and easy.) + */ + +declare(strict_types=1); + +namespace WpPlayground\Php52Downgrader\Visitor; + +use PhpParser\Node; +use PhpParser\Node\Arg; +use PhpParser\Node\Expr; +use PhpParser\NodeVisitorAbstract; + +class ArrayDerefOnCallVisitor extends NodeVisitorAbstract +{ + /** @var bool */ + public bool $used = false; + + public function leaveNode(Node $node) + { + if (!$node instanceof Expr\ArrayDimFetch) { + return null; + } + if ($node->dim === null) { + return null; + } + $target = $node->var; + if ( + !$target instanceof Expr\FuncCall + && !$target instanceof Expr\MethodCall + && !$target instanceof Expr\StaticCall + && !$target instanceof Expr\NullsafeMethodCall + && !$target instanceof Expr\New_ + ) { + return null; + } + $this->used = true; + return new Expr\FuncCall( + new Node\Name('_pg52_at'), + [ + new Arg($target), + new Arg($node->dim), + ], + $node->getAttributes() + ); + } +} diff --git a/scripts/php52-downgrader/src/Visitor/AttributeAndDeclareStripVisitor.php b/scripts/php52-downgrader/src/Visitor/AttributeAndDeclareStripVisitor.php new file mode 100644 index 00000000000..9a61c58b708 --- /dev/null +++ b/scripts/php52-downgrader/src/Visitor/AttributeAndDeclareStripVisitor.php @@ -0,0 +1,55 @@ +attrGroups = []; + } + } + return null; + } + + public function leaveNode(Node $node) + { + if ($node instanceof Stmt\Declare_) { + // Drop declare(strict_types=1); entirely. Leave non- + // strict_types declares alone (ticks, etc). + foreach ($node->declares as $decl) { + if ( + $decl->key instanceof Node\Identifier + && $decl->key->name === 'strict_types' + ) { + return NodeVisitor::REMOVE_NODE; + } + } + } + return null; + } +} diff --git a/scripts/php52-downgrader/src/Visitor/CallableExprVisitor.php b/scripts/php52-downgrader/src/Visitor/CallableExprVisitor.php new file mode 100644 index 00000000000..b8c34469ae8 --- /dev/null +++ b/scripts/php52-downgrader/src/Visitor/CallableExprVisitor.php @@ -0,0 +1,56 @@ +callback)($args) => call_user_func($obj->callback, $args) + * ($var)($args) => call_user_func($var, $args) (dropped — $var($args) works on 5.2) + * (($a) ? $b : $c)($args) => call_user_func(($a) ? $b : $c, $args) + * + * PHP 5.4 added the ability to chain a call onto any expression. PHP + * 5.2 supports only named function calls (`foo()`), method calls + * (`$obj->foo()`), and calling a string-valued variable as a bare + * function (`$var()`). Everything else must go through + * `call_user_func()` / `call_user_func_array()`. + * + * We recognize a call expression as "complex" when the `name` on the + * FuncCall node is neither a Name (named function) nor a plain + * Variable (the PHP 5.2 `$var()` shape). That covers callable + * property fetches, array dim fetches, ternaries, and so on. + */ + +declare(strict_types=1); + +namespace WpPlayground\Php52Downgrader\Visitor; + +use PhpParser\Node; +use PhpParser\Node\Arg; +use PhpParser\Node\Expr; +use PhpParser\NodeVisitorAbstract; + +class CallableExprVisitor extends NodeVisitorAbstract +{ + public function leaveNode(Node $node) + { + if (!$node instanceof Expr\FuncCall) { + return null; + } + $name = $node->name; + if ($name instanceof Node\Name) { + return null; + } + if ($name instanceof Expr\Variable) { + // $var($args) is valid on PHP 5.2 when $var is a string. + return null; + } + // Everything else becomes call_user_func(expr, args...). + $newArgs = [new Arg($name)]; + foreach ($node->args as $arg) { + $newArgs[] = $arg; + } + return new Expr\FuncCall( + new Node\Name('call_user_func'), + $newArgs, + $node->getAttributes() + ); + } +} diff --git a/scripts/php52-downgrader/src/Visitor/ClassKeywordVisitor.php b/scripts/php52-downgrader/src/Visitor/ClassKeywordVisitor.php new file mode 100644 index 00000000000..c0291932955 --- /dev/null +++ b/scripts/php52-downgrader/src/Visitor/ClassKeywordVisitor.php @@ -0,0 +1,49 @@ + 'Foo' + * self::class => get_class() + * static::class => get_called_class() + * + * `get_class()` called without arguments returns the name of the + * class in which the call is made — equivalent to `self::class` in + * the method scopes where the SQLite plugin uses it. Outside a method + * the output is nonsense, but the upstream plugin never uses + * `self::class` at class body scope. + */ + +declare(strict_types=1); + +namespace WpPlayground\Php52Downgrader\Visitor; + +use PhpParser\Node; +use PhpParser\Node\Expr; +use PhpParser\NodeVisitorAbstract; + +class ClassKeywordVisitor extends NodeVisitorAbstract +{ + public function leaveNode(Node $node) + { + if (!$node instanceof Expr\ClassConstFetch) { + return null; + } + if (!$node->name instanceof Node\Identifier) { + return null; + } + if ($node->name->name !== 'class') { + return null; + } + if ($node->class instanceof Node\Name) { + $name = $node->class->toString(); + if ($name === 'self') { + return new Expr\FuncCall(new Node\Name('get_class')); + } + if ($name === 'static') { + return new Expr\FuncCall(new Node\Name('get_called_class')); + } + return new Node\Scalar\String_($name, $node->getAttributes()); + } + return null; + } +} diff --git a/scripts/php52-downgrader/src/Visitor/ClosureHoistingVisitor.php b/scripts/php52-downgrader/src/Visitor/ClosureHoistingVisitor.php new file mode 100644 index 00000000000..3ba994051f3 --- /dev/null +++ b/scripts/php52-downgrader/src/Visitor/ClosureHoistingVisitor.php @@ -0,0 +1,588 @@ + ...`) into top-level named functions. + * + * PHP 5.2 has no closures at all. For each `Expr\Closure` or + * `Expr\ArrowFunction` node we: + * + * 1. Generate a deterministic, file-unique helper name based on a + * hash of the file path plus the closure's source order. + * 2. Build a top-level `Stmt\Function_` carrying the closure's + * parameter list and body, prepended with `$captured = $GLOBALS[...]` + * reads for each captured variable (`use` clause entries plus + * an implicit `$__pg_this` when the body references `$this`). + * 3. Replace the closure expression with a small setter-call + * expression: + * + * _pg52_set_capture('helper_name', array( + * 'cap1' => $cap1, + * '__pg_this' => $this, + * ... + * )) + * + * which, at runtime, stashes the captures in $GLOBALS and returns + * the helper name. PHP 5.2 lets you call a function via + * `$var()` when `$var` is a string, so subsequent invocations of + * the returned name work transparently. + * + * 4. If the closure had no captures and no `$this` reference, skip + * the setter entirely and emit a bare string literal instead. + * + * 5. Append the helper function (and the capture setter helper, and + * the `(new X())->y` helpers if used) to the end of the file so + * they are available by the time the returned name is invoked. + * + * `$this` handling: when the body uses `$this`, we rename every + * `$this` reference inside the closure body to `$__pg_this`, add + * `$__pg_this` to the capture map, and emit the helper as an + * ordinary function that unpacks `$__pg_this` from $GLOBALS. This + * loses access to private/protected members of the enclosing class, + * so the AST pipeline also runs `PromoteForHoistedClosuresVisitor` + * to widen affected members to public in files that contain + * closures referencing `$this`. + * + * `Closure::fromCallable()`, `Closure::bind()`, and explicit static + * closures (`static function () {}`) are downgraded the same way. + * The `static` keyword is dropped because a hoisted top-level + * function has no $this by default on 5.2. + */ + +declare(strict_types=1); + +namespace WpPlayground\Php52Downgrader\Visitor; + +use PhpParser\Modifiers; +use PhpParser\Node; +use PhpParser\Node\Arg; +use PhpParser\Node\Expr; +use PhpParser\Node\Stmt; +use PhpParser\NodeTraverser; +use PhpParser\NodeVisitorAbstract; + +class ClosureHoistingVisitor extends NodeVisitorAbstract +{ + /** @var string */ + private string $relPath; + + /** @var string */ + private string $fileSlug; + + /** @var int */ + private int $counter = 0; + + /** @var Stmt\Function_[] */ + private array $helpers = []; + + /** @var bool */ + private bool $hadThisClosures = false; + + /** @var bool */ + private bool $needsCaptureHelper = false; + + /** + * Stack of (className, parentName) pairs for the classes we are + * currently inside. Pushed in enterNode for Stmt\Class_, popped in + * leaveNode. The top of the stack tells us which class scope a + * closure being hoisted came from — needed because hoisting moves + * the body to top-level scope where `self::`/`parent::`/`static::` + * become fatal errors. + * + * @var array + */ + private array $classStack = []; + + public function __construct(string $relPath) + { + $this->relPath = $relPath; + $this->fileSlug = $this->slugify($relPath); + } + + public function beforeTraverse(array $nodes) + { + $this->counter = 0; + $this->helpers = []; + $this->hadThisClosures = false; + $this->needsCaptureHelper = false; + $this->classStack = []; + return null; + } + + public function enterNode(Node $node) + { + if ($node instanceof Stmt\Class_) { + $this->classStack[] = [ + 'class' => $node->name !== null ? $node->name->toString() : '', + 'parent' => $node->extends !== null ? $node->extends->toString() : null, + ]; + } + return null; + } + + public function leaveNode(Node $node) + { + if ($node instanceof Stmt\Class_) { + array_pop($this->classStack); + return null; + } + if ($node instanceof Expr\ArrowFunction) { + // Convert arrow function to a regular closure first. + // Arrow functions auto-capture all used variables by value; + // the outer pass then hoists them. We do it in two steps so + // that the variable collection logic only has to handle + // one node type. + $body = [new Stmt\Return_($node->expr)]; + $uses = $this->collectArrowCaptures($node->expr, $node->params); + $closure = new Expr\Closure([ + 'static' => $node->static, + 'byRef' => false, + 'params' => $node->params, + 'uses' => $uses, + 'returnType' => null, + 'stmts' => $body, + ], $node->getAttributes()); + return $this->hoistClosure($closure); + } + if ($node instanceof Expr\Closure) { + return $this->hoistClosure($node); + } + return null; + } + + public function afterTraverse(array $nodes) + { + $trailing = []; + // Always emit the runtime helper shims — they're cheap, + // guarded by function_exists, and it means downstream + // visitors don't need to coordinate on who "owns" a helper. + $trailing[] = $this->buildRuntimeHelperBlock(); + if ($this->needsCaptureHelper) { + $trailing[] = $this->buildCaptureHelperWrapper(); + } + foreach ($this->helpers as $helper) { + $trailing[] = $this->wrapInFunctionExistsGuard($helper); + } + return array_merge($nodes, $trailing); + } + + /** + * Returns a single If_ node that conditionally defines all of the + * fixed runtime helpers (_pg52_at, _pg52_call, _pg52_get). The + * block is emitted verbatim into every file; the guards ensure + * multiple-include is safe. + */ + private function buildRuntimeHelperBlock(): Stmt\If_ + { + $cond = new Expr\BooleanNot( + new Expr\FuncCall( + new Node\Name('function_exists'), + [new Arg(new Node\Scalar\String_('_pg52_at'))] + ) + ); + return new Stmt\If_( + $cond, + [ + 'stmts' => [ + $this->buildAtHelper(), + $this->buildCallHelper(), + $this->buildGetHelper(), + ], + ] + ); + } + + /** + * `function _pg52_at($arr, $idx) { return (is_array($arr) && array_key_exists($idx, $arr)) ? $arr[$idx] : null; }` + */ + private function buildAtHelper(): Stmt\Function_ + { + $arr = new Expr\Variable('arr'); + $idx = new Expr\Variable('idx'); + $cond = new Expr\BinaryOp\BooleanAnd( + new Expr\FuncCall(new Node\Name('is_array'), [new Arg($arr)]), + new Expr\FuncCall( + new Node\Name('array_key_exists'), + [new Arg($idx), new Arg($arr)] + ) + ); + $true = new Expr\ArrayDimFetch($arr, $idx); + $false = new Expr\ConstFetch(new Node\Name('null')); + return new Stmt\Function_( + '_pg52_at', + [ + 'byRef' => false, + 'params' => [new Node\Param($arr), new Node\Param($idx)], + 'returnType' => null, + 'stmts' => [ + new Stmt\Return_(new Expr\Ternary($cond, $true, $false)), + ], + ] + ); + } + + /** + * `function _pg52_call($obj, $method, $args) { return call_user_func_array(array($obj, $method), $args); }` + */ + private function buildCallHelper(): Stmt\Function_ + { + $obj = new Expr\Variable('obj'); + $method = new Expr\Variable('method'); + $args = new Expr\Variable('args'); + $pair = new Expr\Array_([ + new Node\ArrayItem($obj), + new Node\ArrayItem($method), + ]); + $call = new Expr\FuncCall( + new Node\Name('call_user_func_array'), + [new Arg($pair), new Arg($args)] + ); + return new Stmt\Function_( + '_pg52_call', + [ + 'byRef' => false, + 'params' => [ + new Node\Param($obj), + new Node\Param($method), + new Node\Param($args), + ], + 'returnType' => null, + 'stmts' => [new Stmt\Return_($call)], + ] + ); + } + + /** + * `function _pg52_get($obj, $prop) { return $obj->{$prop}; }` + */ + private function buildGetHelper(): Stmt\Function_ + { + $obj = new Expr\Variable('obj'); + $prop = new Expr\Variable('prop'); + return new Stmt\Function_( + '_pg52_get', + [ + 'byRef' => false, + 'params' => [new Node\Param($obj), new Node\Param($prop)], + 'returnType' => null, + 'stmts' => [ + new Stmt\Return_(new Expr\PropertyFetch($obj, $prop)), + ], + ] + ); + } + + public function hadThisClosures(): bool + { + return $this->hadThisClosures; + } + + // ───────────────────────────────────────────────────────────── + // Hoisting core + // ───────────────────────────────────────────────────────────── + + private function hoistClosure(Expr\Closure $closure): Node + { + // By-reference captures (`use (&$x)`) cannot be faithfully + // emulated by a top-level hoisted helper: the helper reads + // captures from $GLOBALS, which loses the reference binding + // back to the caller's scope. Silently converting to by-value + // would be a semantic miscompile, so bail out loudly and let + // the operator rewrite the source. + foreach ($closure->uses as $use) { + if ($use->byRef) { + $varName = $use->var instanceof Expr\Variable && is_string($use->var->name) + ? $use->var->name + : '?'; + throw new \RuntimeException(sprintf( + "ClosureHoistingVisitor: cannot hoist closure with " . + "by-reference capture 'use (&\$%s)' in %s", + $varName, + $this->relPath + )); + } + } + $this->counter++; + $helperName = sprintf( + '_wp_pg52_%s_closure_%d', + $this->fileSlug, + $this->counter + ); + // CRITICAL: a closure's body may reference `self::CONST`, + // `parent::method()`, `static::$prop`, etc. After hoisting, + // the body lives in a top-level function with no class scope + // — those references would be fatal at runtime ("Cannot + // access self:: when no class scope is active"). Rewrite them + // to the literal class name BEFORE hoisting. + if (!empty($this->classStack)) { + $ctx = end($this->classStack); + SelfParentStaticRewriter::rewriteInStmts( + $closure->stmts, + $ctx['class'], + $ctx['parent'], + 'hoisted closure' + ); + } + $bodyUsesThis = $this->bodyUsesThis($closure->stmts); + $uses = $closure->uses; + if ($bodyUsesThis) { + $this->hadThisClosures = true; + // Rewrite `$this` references inside the body to + // `$__pg_this`. Needs its own traversal so we don't + // reach sibling siblings' `$this` in nested closures. + $this->renameThisInStmts($closure->stmts); + $uses[] = new Expr\ClosureUse( + new Expr\Variable('__pg_this'), + false + ); + } + // Build capture preamble (reads from $GLOBALS at call time). + $preamble = []; + foreach ($uses as $use) { + $name = $use->var->name; + $globalKey = $helperName . '_capture'; + $fetch = new Expr\ArrayDimFetch( + new Expr\ArrayDimFetch( + new Expr\Variable('GLOBALS'), + new Node\Scalar\String_($globalKey) + ), + new Node\Scalar\String_($name) + ); + $ternary = new Expr\Ternary( + new Expr\Isset_([$fetch]), + $fetch, + new Expr\ConstFetch(new Node\Name('null')) + ); + $preamble[] = new Stmt\Expression( + new Expr\Assign( + new Expr\Variable($name), + $ternary + ) + ); + } + $helper = new Stmt\Function_( + $helperName, + [ + 'byRef' => $closure->byRef, + 'params' => $closure->params, + 'returnType' => null, + 'stmts' => array_merge($preamble, $closure->stmts), + ] + ); + $this->helpers[] = $helper; + + if (count($uses) === 0) { + // No captures — emit the string literal directly. + return new Node\Scalar\String_($helperName, $closure->getAttributes()); + } + + $this->needsCaptureHelper = true; + // Build the `_pg52_set_capture('name', array(...))` call site. + // The capture array maps the helper-side variable name (which + // inside the helper body is read from $GLOBALS) to the + // outer-side value being captured. For ordinary `use ($foo)` + // captures these are the same variable; for the implicit + // `__pg_this` capture the outer side is `$this` (which is + // what the closure was originally bound to in the source). + $captureItems = []; + foreach ($uses as $use) { + $name = $use->var->name; + $outerExpr = $name === '__pg_this' + ? new Expr\Variable('this') + : new Expr\Variable($name); + $captureItems[] = new Node\ArrayItem( + $outerExpr, + new Node\Scalar\String_($name) + ); + } + return new Expr\FuncCall( + new Node\Name('_pg52_set_capture'), + [ + new Arg(new Node\Scalar\String_($helperName)), + new Arg(new Expr\Array_($captureItems)), + ], + $closure->getAttributes() + ); + } + + /** + * Determines the list of variables an arrow function captures + * implicitly. An arrow function captures every free variable used + * inside the expression that isn't a parameter. + * + * @param Node\Param[] $params + * @return Expr\ClosureUse[] + */ + private function collectArrowCaptures(Expr $expr, array $params): array + { + $paramNames = []; + foreach ($params as $p) { + if ($p->var instanceof Expr\Variable && is_string($p->var->name)) { + $paramNames[$p->var->name] = true; + } + } + $collector = new class ($paramNames) extends NodeVisitorAbstract { + /** @var array */ + private array $paramNames; + /** @var array */ + public array $found = []; + + public function __construct(array $paramNames) + { + $this->paramNames = $paramNames; + } + + public function enterNode(Node $node) + { + if ( + $node instanceof Expr\Variable + && is_string($node->name) + && $node->name !== 'this' + && !isset($this->paramNames[$node->name]) + ) { + $this->found[$node->name] = true; + } + return null; + } + }; + $t = new NodeTraverser(); + $t->addVisitor($collector); + $t->traverse([new Stmt\Expression($expr)]); + $uses = []; + foreach (array_keys($collector->found) as $name) { + $uses[] = new Expr\ClosureUse(new Expr\Variable($name), false); + } + return $uses; + } + + /** + * @param array $stmts + */ + private function bodyUsesThis(array $stmts): bool + { + $checker = new class () extends NodeVisitorAbstract { + public bool $found = false; + public function enterNode(Node $node) + { + if ( + $node instanceof Expr\Variable + && is_string($node->name) + && $node->name === 'this' + ) { + $this->found = true; + } + // Don't descend into nested closures — their $this + // belongs to a different scope. + if ( + $node instanceof Expr\Closure + || $node instanceof Expr\ArrowFunction + ) { + return NodeTraverser::DONT_TRAVERSE_CURRENT_AND_CHILDREN; + } + return null; + } + }; + $t = new NodeTraverser(); + $t->addVisitor($checker); + $t->traverse($stmts); + return $checker->found; + } + + /** + * @param array $stmts + */ + private function renameThisInStmts(array &$stmts): void + { + $renamer = new class () extends NodeVisitorAbstract { + public function enterNode(Node $node) + { + if ( + $node instanceof Expr\Closure + || $node instanceof Expr\ArrowFunction + ) { + return NodeTraverser::DONT_TRAVERSE_CURRENT_AND_CHILDREN; + } + return null; + } + + public function leaveNode(Node $node) + { + if ( + $node instanceof Expr\Variable + && is_string($node->name) + && $node->name === 'this' + ) { + return new Expr\Variable('__pg_this', $node->getAttributes()); + } + return null; + } + }; + $t = new NodeTraverser(); + $t->addVisitor($renamer); + $stmts = $t->traverse($stmts); + } + + // ───────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────── + + private function slugify(string $path): string + { + // Stable prefix for readable names in the output. + $base = preg_replace('/[^a-zA-Z0-9]+/', '_', $path); + $base = trim($base, '_'); + if ($base === '') { + $base = 'file'; + } + return $base; + } + + private function buildCaptureHelperWrapper(): Stmt\If_ + { + $funcNameExists = new Expr\FuncCall( + new Node\Name('function_exists'), + [new Arg(new Node\Scalar\String_('_pg52_set_capture'))] + ); + $notExists = new Expr\BooleanNot($funcNameExists); + $fn = new Stmt\Function_( + '_pg52_set_capture', + [ + 'byRef' => false, + 'params' => [ + new Node\Param(new Expr\Variable('name')), + new Node\Param(new Expr\Variable('captures')), + ], + 'returnType' => null, + 'stmts' => [ + new Stmt\Expression( + new Expr\Assign( + new Expr\ArrayDimFetch( + new Expr\Variable('GLOBALS'), + new Expr\BinaryOp\Concat( + new Expr\Variable('name'), + new Node\Scalar\String_('_capture') + ) + ), + new Expr\Variable('captures') + ) + ), + new Stmt\Return_(new Expr\Variable('name')), + ], + ] + ); + return new Stmt\If_( + $notExists, + ['stmts' => [$fn]] + ); + } + + private function wrapInFunctionExistsGuard(Stmt\Function_ $fn): Stmt\If_ + { + $cond = new Expr\BooleanNot( + new Expr\FuncCall( + new Node\Name('function_exists'), + [new Arg(new Node\Scalar\String_($fn->name->toString()))] + ) + ); + return new Stmt\If_($cond, ['stmts' => [$fn]]); + } +} diff --git a/scripts/php52-downgrader/src/Visitor/DirConstantVisitor.php b/scripts/php52-downgrader/src/Visitor/DirConstantVisitor.php new file mode 100644 index 00000000000..1a170ec7dd6 --- /dev/null +++ b/scripts/php52-downgrader/src/Visitor/DirConstantVisitor.php @@ -0,0 +1,36 @@ + `dirname(__FILE__)`. + * + * The pretty printer subclass {@see \WpPlayground\Php52Downgrader\PrettyPrinter} + * already emits `dirname(__FILE__)` in place of the magic constant, so + * most files are handled there. This visitor is kept as a separate + * pass because it lets later visitors see a concrete function call + * instead of a magic constant — notably {@see ArrayClassConstantVisitor} + * needs to recognize that `__DIR__ . 'x'` is a non-constant expression + * and hoist the const. + */ + +declare(strict_types=1); + +namespace WpPlayground\Php52Downgrader\Visitor; + +use PhpParser\Node; +use PhpParser\Node\Arg; +use PhpParser\Node\Expr; +use PhpParser\NodeVisitorAbstract; + +class DirConstantVisitor extends NodeVisitorAbstract +{ + public function leaveNode(Node $node) + { + if ($node instanceof Node\Scalar\MagicConst\Dir) { + return new Expr\FuncCall( + new Node\Name('dirname'), + [new Arg(new Node\Scalar\MagicConst\File($node->getAttributes()))], + $node->getAttributes() + ); + } + return null; + } +} diff --git a/scripts/php52-downgrader/src/Visitor/ExponentVisitor.php b/scripts/php52-downgrader/src/Visitor/ExponentVisitor.php new file mode 100644 index 00000000000..6013d40a92e --- /dev/null +++ b/scripts/php52-downgrader/src/Visitor/ExponentVisitor.php @@ -0,0 +1,39 @@ +left), new Arg($node->right)], + $node->getAttributes() + ); + } + if ($node instanceof Expr\AssignOp\Pow) { + return new Expr\Assign( + $node->var, + new Expr\FuncCall( + new Node\Name('pow'), + [new Arg(clone $node->var), new Arg($node->expr)] + ), + $node->getAttributes() + ); + } + return null; + } +} diff --git a/scripts/php52-downgrader/src/Visitor/FinallyVisitor.php b/scripts/php52-downgrader/src/Visitor/FinallyVisitor.php new file mode 100644 index 00000000000..59fb5b7c895 --- /dev/null +++ b/scripts/php52-downgrader/src/Visitor/FinallyVisitor.php @@ -0,0 +1,72 @@ +finally === null) { + return null; + } + $finallyStmts = $node->finally->stmts; + $cloneFinally = function () use ($finallyStmts) { + return array_map(fn(Stmt $s) => clone $s, $finallyStmts); + }; + $newTryStmts = array_merge($node->stmts, $cloneFinally()); + $newCatches = []; + if (count($node->catches) === 0) { + $rethrow = new Stmt\Expression( + new Expr\Throw_(new Expr\Variable('__pg_fe')) + ); + $catchStmts = array_merge($cloneFinally(), [$rethrow]); + $newCatches[] = new Stmt\Catch_( + [new Node\Name('Exception')], + new Expr\Variable('__pg_fe'), + $catchStmts + ); + } else { + foreach ($node->catches as $c) { + $c = clone $c; + $c->stmts = array_merge($c->stmts, $cloneFinally()); + $newCatches[] = $c; + } + } + return new Stmt\TryCatch( + $newTryStmts, + $newCatches, + null, + $node->getAttributes() + ); + } +} diff --git a/scripts/php52-downgrader/src/Visitor/InstanceCallOnNewVisitor.php b/scripts/php52-downgrader/src/Visitor/InstanceCallOnNewVisitor.php new file mode 100644 index 00000000000..2bde3609167 --- /dev/null +++ b/scripts/php52-downgrader/src/Visitor/InstanceCallOnNewVisitor.php @@ -0,0 +1,68 @@ +bar(...)` and `(new Foo($a))->prop` + * directly. PHP 5.2 does not — the parser refuses to chain a member + * access onto the `new` expression. We rewrite each occurrence to a + * small runtime helper: + * + * (new Foo($a))->bar($b) => _pg52_call(new Foo($a), 'bar', array($b)) + * (new Foo($a))->prop => _pg52_get(new Foo($a), 'prop') + * + * The helpers themselves are emitted by the closure hoisting visitor's + * trailing helper block, via {@see HelperEmitterVisitor}. When the + * downgraded plugin doesn't hit either of these shapes the helpers + * stay out of the output. + * + * The visitor records which helpers were used so the hoister knows + * which definitions to append. + */ + +declare(strict_types=1); + +namespace WpPlayground\Php52Downgrader\Visitor; + +use PhpParser\Node; +use PhpParser\Node\Arg; +use PhpParser\Node\Expr; +use PhpParser\NodeVisitorAbstract; + +class InstanceCallOnNewVisitor extends NodeVisitorAbstract +{ + public function leaveNode(Node $node) + { + if ($node instanceof Expr\MethodCall && $node->var instanceof Expr\New_) { + $method = $node->name instanceof Node\Identifier + ? new Node\Scalar\String_($node->name->toString()) + : $node->name; + $argsArr = new Expr\Array_(array_map( + fn(Arg $a) => new Node\ArrayItem($a->value), + $node->args + )); + return new Expr\FuncCall( + new Node\Name('_pg52_call'), + [ + new Arg($node->var), + new Arg($method), + new Arg($argsArr), + ], + $node->getAttributes() + ); + } + if ($node instanceof Expr\PropertyFetch && $node->var instanceof Expr\New_) { + $prop = $node->name instanceof Node\Identifier + ? new Node\Scalar\String_($node->name->toString()) + : $node->name; + return new Expr\FuncCall( + new Node\Name('_pg52_get'), + [ + new Arg($node->var), + new Arg($prop), + ], + $node->getAttributes() + ); + } + return null; + } +} diff --git a/scripts/php52-downgrader/src/Visitor/LateStaticBindingVisitor.php b/scripts/php52-downgrader/src/Visitor/LateStaticBindingVisitor.php new file mode 100644 index 00000000000..2816ff44969 --- /dev/null +++ b/scripts/php52-downgrader/src/Visitor/LateStaticBindingVisitor.php @@ -0,0 +1,33 @@ +class instanceof Node\Name + && $node->class->toString() === 'static' + ) { + $node->class = new Node\Name('self', $node->class->getAttributes()); + } + return null; + } +} diff --git a/scripts/php52-downgrader/src/Visitor/NamespaceStripVisitor.php b/scripts/php52-downgrader/src/Visitor/NamespaceStripVisitor.php new file mode 100644 index 00000000000..8553245aabf --- /dev/null +++ b/scripts/php52-downgrader/src/Visitor/NamespaceStripVisitor.php @@ -0,0 +1,68 @@ +stmts as $inner) { + if ($inner instanceof Stmt\Use_) { + // Drop use statements; after NameResolver has + // run, references are already fully resolved. + continue; + } + $out[] = $inner; + } + continue; + } + $out[] = $node; + } + return $out; + } + + public function enterNode(Node $node) + { + if ($node instanceof Name) { + // Turn any Name variant into an unqualified identifier. + // After NameResolver, the Name contains fully-qualified + // components like `Foo\Bar`. For PHP 5.2 we keep only the + // last component (`Bar`). This is safe here because the + // SQLite plugin has no real namespaces. + if ($node->isFullyQualified() || $node->isQualified()) { + $parts = $node->getParts(); + return new Name([end($parts)], $node->getAttributes()); + } + } + return null; + } +} diff --git a/scripts/php52-downgrader/src/Visitor/NullCoalescingVisitor.php b/scripts/php52-downgrader/src/Visitor/NullCoalescingVisitor.php new file mode 100644 index 00000000000..0a9f07a5739 --- /dev/null +++ b/scripts/php52-downgrader/src/Visitor/NullCoalescingVisitor.php @@ -0,0 +1,140 @@ +tempCounter = 0; + return null; + } + + public function leaveNode(Node $node) + { + if ($node instanceof Expr\AssignOp\Coalesce) { + // $x ??= $y: cloning the LHS is only safe when the LHS is a + // trivially duplicatable l-value. For anything else (e.g. + // `$obj->method()->prop ??= ...`) duplicating would double- + // evaluate the receiver chain. We bail out loudly rather + // than silently miscompile — the SQLite plugin currently + // doesn't use `??=` on complex LHS. + if (!$this->isAssignTargetDuplicatable($node->var)) { + throw new \RuntimeException( + 'NullCoalescingVisitor: `??=` on a complex LHS is not ' . + 'supported (would double-evaluate side effects). ' . + 'Rewrite the source to use an intermediate variable.' + ); + } + return new Expr\Assign( + $node->var, + new Expr\BinaryOp\Coalesce( + clone $node->var, + $node->expr, + $node->getAttributes() + ), + $node->getAttributes() + ); + } + if (!$node instanceof Expr\BinaryOp\Coalesce) { + return null; + } + $lhs = $node->left; + $rhs = $node->right; + if ($this->isIssetSafe($lhs)) { + return new Expr\Ternary( + new Expr\Isset_([$lhs]), + $lhs, + $rhs, + $node->getAttributes() + ); + } + // Side-effecting LHS: capture in a temp var. Use a counter- + // suffixed name so nested `??` rewrites don't clobber each + // other (e.g. `a() ?? b() ?? c()`). + $tmpName = '__pg_nc_tmp_' . $this->tempCounter++; + $tmp = new Expr\Variable($tmpName); + $assign = new Expr\Assign($tmp, $lhs); + $notNull = new Expr\BinaryOp\NotIdentical( + $assign, + new Expr\ConstFetch(new Node\Name('null')) + ); + return new Expr\Ternary($notNull, clone $tmp, $rhs, $node->getAttributes()); + } + + /** + * Returns true when the expression is a trivially duplicatable + * l-value: a simple Variable, a simple array-dim fetch on such, + * or a property fetch on a simple Variable. Anything else may + * have side effects that must not be evaluated twice. + */ + private function isAssignTargetDuplicatable(Node\Expr $expr): bool + { + if ($expr instanceof Expr\Variable) { + return true; + } + if ($expr instanceof Expr\PropertyFetch) { + return $expr->var instanceof Expr\Variable; + } + if ($expr instanceof Expr\StaticPropertyFetch) { + return true; + } + if ($expr instanceof Expr\ArrayDimFetch) { + return $this->isAssignTargetDuplicatable($expr->var); + } + return false; + } + + /** + * Returns true when the expression is safe to appear inside an + * `isset()` call (i.e. PHP won't raise E_NOTICE at parse time). + */ + private function isIssetSafe(Node\Expr $expr): bool + { + if ($expr instanceof Expr\Variable) { + return true; + } + if ($expr instanceof Expr\PropertyFetch) { + return $this->isIssetSafe($expr->var); + } + if ($expr instanceof Expr\StaticPropertyFetch) { + return true; + } + if ($expr instanceof Expr\ArrayDimFetch) { + return $expr->var instanceof Expr\Variable + || $expr->var instanceof Expr\PropertyFetch + || $expr->var instanceof Expr\StaticPropertyFetch + || $expr->var instanceof Expr\ArrayDimFetch + || $expr->var instanceof Expr\ClassConstFetch; + } + return false; + } +} diff --git a/scripts/php52-downgrader/src/Visitor/NullsafeVisitor.php b/scripts/php52-downgrader/src/Visitor/NullsafeVisitor.php new file mode 100644 index 00000000000..743bcd95fc3 --- /dev/null +++ b/scripts/php52-downgrader/src/Visitor/NullsafeVisitor.php @@ -0,0 +1,106 @@ +b`. + * + * For plain isset-safe receivers we emit a direct + * `(isset($a) ? $a->b : null)` ternary. For side-effecting receivers + * (function/method calls, chained nullsafe rewrites, ternaries, etc.) + * we hoist into a temp var: + * + * (($__pg_ns_0 = $expr) !== null ? $__pg_ns_0->b : null) + * + * This is necessary for chained `$a?->b?->c`: the inner rewrite + * produces a ternary, which cannot legally appear inside `isset()`. + * Counter-suffixed temp names avoid collisions in nested chains. + */ + +declare(strict_types=1); + +namespace WpPlayground\Php52Downgrader\Visitor; + +use PhpParser\Node; +use PhpParser\Node\Expr; +use PhpParser\NodeVisitorAbstract; + +class NullsafeVisitor extends NodeVisitorAbstract +{ + /** @var int Counter for unique temp var names, reset per file. */ + private int $tempCounter = 0; + + public function beforeTraverse(array $nodes) + { + $this->tempCounter = 0; + return null; + } + + public function leaveNode(Node $node) + { + if ($node instanceof Expr\NullsafePropertyFetch) { + return $this->rewriteNullsafe( + $node->var, + fn(Expr $receiver) => new Expr\PropertyFetch($receiver, $node->name) + ); + } + if ($node instanceof Expr\NullsafeMethodCall) { + return $this->rewriteNullsafe( + $node->var, + fn(Expr $receiver) => new Expr\MethodCall($receiver, $node->name, $node->args) + ); + } + return null; + } + + /** + * Builds the lowered ternary. If the receiver is isset-safe we use + * the direct `isset($x) ? $x->y : null` form; otherwise we capture + * it in a unique temp var and null-compare. + */ + private function rewriteNullsafe(Expr $receiver, \Closure $buildAccess): Expr\Ternary + { + if ($this->isIssetSafe($receiver)) { + return new Expr\Ternary( + new Expr\Isset_([$receiver]), + $buildAccess($receiver), + $this->nullLit() + ); + } + $tmpName = '__pg_ns_' . $this->tempCounter++; + $tmp = new Expr\Variable($tmpName); + $assign = new Expr\Assign($tmp, $receiver); + $notNull = new Expr\BinaryOp\NotIdentical( + $assign, + $this->nullLit() + ); + return new Expr\Ternary( + $notNull, + $buildAccess(clone $tmp), + $this->nullLit() + ); + } + + /** + * Returns true when the expression is safe to appear inside an + * `isset()` call. Matches NullCoalescingVisitor::isIssetSafe. + */ + private function isIssetSafe(Node\Expr $expr): bool + { + if ($expr instanceof Expr\Variable) { + return true; + } + if ($expr instanceof Expr\PropertyFetch) { + return $this->isIssetSafe($expr->var); + } + if ($expr instanceof Expr\StaticPropertyFetch) { + return true; + } + if ($expr instanceof Expr\ArrayDimFetch) { + return $this->isIssetSafe($expr->var); + } + return false; + } + + private function nullLit(): Expr\ConstFetch + { + return new Expr\ConstFetch(new Node\Name('null')); + } +} diff --git a/scripts/php52-downgrader/src/Visitor/Php7ErrorClassesVisitor.php b/scripts/php52-downgrader/src/Visitor/Php7ErrorClassesVisitor.php new file mode 100644 index 00000000000..13d0fef85cb --- /dev/null +++ b/scripts/php52-downgrader/src/Visitor/Php7ErrorClassesVisitor.php @@ -0,0 +1,89 @@ +types = array_map( + [$this, 'remapName'], + $node->types + ); + // Deduplicate Exception entries. + $seen = []; + $unique = []; + foreach ($node->types as $t) { + $key = $t->toString(); + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + $unique[] = $t; + } + $node->types = $unique; + return null; + } + + if ($node instanceof Node\Expr\New_ && $node->class instanceof Name) { + $node->class = $this->remapName($node->class); + return null; + } + + if ($node instanceof Stmt\Class_) { + if ($node->extends instanceof Name) { + $node->extends = $this->remapName($node->extends); + } + return null; + } + + if ($node instanceof Node\Expr\Instanceof_ && $node->class instanceof Name) { + $node->class = $this->remapName($node->class); + return null; + } + + return null; + } + + private function remapName(Name $name): Name + { + if (in_array($name->toString(), self::REMAPPED, true)) { + return new Name('Exception', $name->getAttributes()); + } + return $name; + } +} diff --git a/scripts/php52-downgrader/src/Visitor/PromoteForHoistedClosuresVisitor.php b/scripts/php52-downgrader/src/Visitor/PromoteForHoistedClosuresVisitor.php new file mode 100644 index 00000000000..0254b370bac --- /dev/null +++ b/scripts/php52-downgrader/src/Visitor/PromoteForHoistedClosuresVisitor.php @@ -0,0 +1,158 @@ +classContainsThisClosure($node)) { + return null; + } + foreach ($node->stmts as $stmt) { + if ($stmt instanceof Stmt\Property) { + $stmt->flags = self::makePublic($stmt->flags); + } elseif ($stmt instanceof Stmt\ClassMethod) { + $stmt->flags = self::makePublic($stmt->flags); + } + } + return null; + } + + /** + * True if any closure (or arrow function) inside this class body + * — at any nesting depth, but excluding nested-class scopes — + * references `$this`. Closures with no `$this` reference do not + * need member-visibility promotion because the helper body never + * touches an instance. + */ + private function classContainsThisClosure(Stmt\Class_ $class): bool + { + $found = false; + $visitor = new class () extends NodeVisitorAbstract { + public bool $found = false; + /** @var int Nested class depth — refs to `$this` inside an + * anonymous inner class belong to a different + * scope and don't trigger promotion of the + * outer. We never enter the *outer* class itself + * because the caller passes its body directly. */ + private int $nestedDepth = 0; + + public function enterNode(Node $node) + { + if ($node instanceof Stmt\Class_) { + $this->nestedDepth++; + return null; + } + if ($this->nestedDepth > 0) { + return null; + } + if ( + $node instanceof Expr\Closure + || $node instanceof Expr\ArrowFunction + ) { + if ($this->closureUsesThis($node)) { + $this->found = true; + return NodeTraverser::STOP_TRAVERSAL; + } + } + return null; + } + + public function leaveNode(Node $node) + { + if ($node instanceof Stmt\Class_) { + $this->nestedDepth--; + } + return null; + } + + private function closureUsesThis(Node $closure): bool + { + $found = false; + $walker = new class () extends NodeVisitorAbstract { + public bool $found = false; + public function enterNode(Node $n) + { + if ( + $n instanceof Expr\Variable + && is_string($n->name) + && $n->name === 'this' + ) { + $this->found = true; + return NodeTraverser::STOP_TRAVERSAL; + } + if ( + $n instanceof Expr\Closure + || $n instanceof Expr\ArrowFunction + ) { + // Don't descend into nested closures; their + // `$this` belongs to their own scope. The + // outer closure's loop will visit them + // independently. + return NodeTraverser::DONT_TRAVERSE_CURRENT_AND_CHILDREN; + } + return null; + } + }; + $traverser = new NodeTraverser(); + $traverser->addVisitor($walker); + $body = $closure instanceof Expr\Closure + ? $closure->stmts + : [new Stmt\Return_($closure->expr)]; + $traverser->traverse($body); + return $walker->found; + } + }; + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + // Walk the class body but NOT the class node itself (to avoid + // the nestedDepth bookkeeping running on the outer class). + $traverser->traverse($class->stmts); + return $visitor->found; + } + + /** + * Clears the PROTECTED and PRIVATE flag bits and sets PUBLIC. + * Preserves STATIC/ABSTRACT/FINAL. + */ + private static function makePublic(int $flags): int + { + $flags &= ~(Modifiers::PROTECTED | Modifiers::PRIVATE); + $flags |= Modifiers::PUBLIC; + return $flags; + } +} diff --git a/scripts/php52-downgrader/src/Visitor/ReservedMethodRenameVisitor.php b/scripts/php52-downgrader/src/Visitor/ReservedMethodRenameVisitor.php new file mode 100644 index 00000000000..2223c6aad78 --- /dev/null +++ b/scripts/php52-downgrader/src/Visitor/ReservedMethodRenameVisitor.php @@ -0,0 +1,44 @@ + new name. */ + private const RENAMES = [ + 'throw' => 'throwError', + ]; + + public function enterNode(Node $node) + { + if ($node instanceof Stmt\ClassMethod && isset(self::RENAMES[$node->name->name])) { + $node->name = new Node\Identifier(self::RENAMES[$node->name->name]); + return null; + } + if ($node instanceof Expr\MethodCall && $node->name instanceof Node\Identifier + && isset(self::RENAMES[$node->name->name])) { + $node->name = new Node\Identifier(self::RENAMES[$node->name->name]); + return null; + } + if ($node instanceof Expr\StaticCall && $node->name instanceof Node\Identifier + && isset(self::RENAMES[$node->name->name])) { + $node->name = new Node\Identifier(self::RENAMES[$node->name->name]); + return null; + } + return null; + } +} diff --git a/scripts/php52-downgrader/src/Visitor/SelfParentStaticRewriter.php b/scripts/php52-downgrader/src/Visitor/SelfParentStaticRewriter.php new file mode 100644 index 00000000000..8e95573a7ad --- /dev/null +++ b/scripts/php52-downgrader/src/Visitor/SelfParentStaticRewriter.php @@ -0,0 +1,180 @@ +` assignments. The `` may + * reference `self::OTHER_CONST`, which has to become + * `ClassName::OTHER_CONST` before it leaves the class. + * + * 2. {@see ClosureHoistingVisitor} hoists PHP 5.3+ closures into + * top-level named functions. The body may reference `self::X`, + * `parent::method()`, `static::$prop`, etc., all of which need + * to be rewritten before the body leaves its class scope. + * + * The rewriter walks nested nodes but does NOT descend into an + * inline-defined nested class — references inside such a class + * belong to a different scope. + * + * `static::` is collapsed to `self::` (then to the literal class + * name). This loses runtime late-static-binding semantics, but for + * the two use cases above the value is either captured at class load + * time (constants) or the closure is hosted on a `final` data class, + * so the distinction is irrelevant. + * + * If `parent::` is encountered and the enclosing class has no + * `extends` clause, we throw a RuntimeException tagged with a + * caller-supplied `$contextHint` — each caller wants its own error + * wording so operators can tell which pipeline stage failed. + */ + +declare(strict_types=1); + +namespace WpPlayground\Php52Downgrader\Visitor; + +use PhpParser\Node; +use PhpParser\Node\Expr; +use PhpParser\Node\Stmt; +use PhpParser\NodeTraverser; +use PhpParser\NodeVisitorAbstract; + +class SelfParentStaticRewriter +{ + /** + * Rewrites every `self::X`, `parent::X`, `static::X`, + * `self::$P`, `parent::$P`, `static::$P`, `self::method()`, etc. + * inside the given expression tree so that the literal class name + * is used. Returns the rewritten expression (the rewriter mutates + * the tree in place but we go through the traverser wrapper to + * keep error propagation consistent with the stmts variant). + * + * @param Node\Expr $expr Expression to transform. + * @param string $className Enclosing class name. + * @param string|null $parentName Enclosing class' parent, or null. + * @param string $contextHint Caller-specific phrase used in + * the thrown RuntimeException when + * `parent::` is encountered and no + * parent is known. Examples: + * "extracted expression", + * "hoisted closure". + */ + public static function rewriteInExpr( + Node\Expr $expr, + string $className, + ?string $parentName, + string $contextHint + ): Node\Expr { + $wrapped = [new Stmt\Expression($expr)]; + self::traverse($wrapped, $className, $parentName, $contextHint); + /** @var Stmt\Expression $wrapper */ + $wrapper = $wrapped[0]; + return $wrapper->expr; + } + + /** + * Rewrites every self/parent/static reference inside the given + * statement list in place (the array is passed by reference so the + * caller sees the traverser's fresh, potentially-replaced nodes). + * + * @param array $stmts Statement list to transform. + * @param string $className Enclosing class name. + * @param string|null $parentName Enclosing class' parent, or null. + * @param string $contextHint See {@see rewriteInExpr()}. + */ + public static function rewriteInStmts( + array &$stmts, + string $className, + ?string $parentName, + string $contextHint + ): void { + self::traverse($stmts, $className, $parentName, $contextHint); + } + + /** + * Runs the shared traverser over a statement list. Both public + * entrypoints funnel through here so the walk and error-reporting + * logic lives in exactly one place. + * + * @param array $stmts + */ + private static function traverse( + array &$stmts, + string $className, + ?string $parentName, + string $contextHint + ): void { + $rewriter = new class ($className, $parentName, $contextHint) extends NodeVisitorAbstract { + /** @var string */ + private string $className; + /** @var string|null */ + private ?string $parentName; + /** @var string */ + private string $contextHint; + /** @var int Nested-class depth so we don't rewrite refs inside an inline class. */ + private int $nestedClassDepth = 0; + + public function __construct(string $className, ?string $parentName, string $contextHint) + { + $this->className = $className; + $this->parentName = $parentName; + $this->contextHint = $contextHint; + } + + public function enterNode(Node $node) + { + if ($node instanceof Stmt\Class_) { + $this->nestedClassDepth++; + } + return null; + } + + public function leaveNode(Node $node) + { + if ($node instanceof Stmt\Class_) { + $this->nestedClassDepth--; + return null; + } + if ($this->nestedClassDepth > 0) { + return null; + } + if ( + $node instanceof Expr\ClassConstFetch + || $node instanceof Expr\StaticPropertyFetch + || $node instanceof Expr\StaticCall + ) { + if (!$node->class instanceof Node\Name) { + return null; + } + $cls = $node->class->toString(); + if ($cls === 'self' || $cls === 'static') { + $node->class = new Node\Name($this->className); + return $node; + } + if ($cls === 'parent') { + if ($this->parentName === null) { + throw new \RuntimeException( + "cannot rewrite parent:: in {$this->contextHint}: " . + "class {$this->className} has no `extends` clause. " . + "Add an explicit parent class or patch the source to avoid parent:: here." + ); + } + $node->class = new Node\Name($this->parentName); + return $node; + } + } + return null; + } + }; + $traverser = new NodeTraverser(); + $traverser->addVisitor($rewriter); + $stmts = $traverser->traverse($stmts); + } +} diff --git a/scripts/php52-downgrader/src/Visitor/ShortTernaryVisitor.php b/scripts/php52-downgrader/src/Visitor/ShortTernaryVisitor.php new file mode 100644 index 00000000000..e28e9834bdc --- /dev/null +++ b/scripts/php52-downgrader/src/Visitor/ShortTernaryVisitor.php @@ -0,0 +1,88 @@ + $var ? $var : $fallback + * $safe->expr ?: $fallback => $safe->expr ? $safe->expr : $fallback + * fn($a) ?: $fallback => (($__pg_st_tmp = fn($a)) ? $__pg_st_tmp : $fallback) + * + * Simple side-effect-free LHSes are duplicated in place. Complex + * LHSes are captured in a temp var so they evaluate once. + */ + +declare(strict_types=1); + +namespace WpPlayground\Php52Downgrader\Visitor; + +use PhpParser\Node; +use PhpParser\Node\Expr; +use PhpParser\NodeVisitorAbstract; + +class ShortTernaryVisitor extends NodeVisitorAbstract +{ + /** @var int Counter for unique temp var names, reset per file. */ + private int $tempCounter = 0; + + public function beforeTraverse(array $nodes) + { + $this->tempCounter = 0; + return null; + } + + public function leaveNode(Node $node) + { + if (!$node instanceof Expr\Ternary) { + return null; + } + if ($node->if !== null) { + // Not a short ternary. + return null; + } + $lhs = $node->cond; + if ($this->isDuplicatable($lhs)) { + return new Expr\Ternary( + $lhs, + clone $lhs, + $node->else, + $node->getAttributes() + ); + } + // Side-effecting LHS: capture in a counter-suffixed temp var + // so nested `a() ?: b() ?: c()` rewrites don't collide. + $tmpName = '__pg_st_tmp_' . $this->tempCounter++; + $tmp = new Expr\Variable($tmpName); + $assign = new Expr\Assign($tmp, $lhs); + return new Expr\Ternary( + $assign, + clone $tmp, + $node->else, + $node->getAttributes() + ); + } + + private function isDuplicatable(Node\Expr $expr): bool + { + if ($expr instanceof Expr\Variable) { + return true; + } + if ($expr instanceof Expr\PropertyFetch) { + return $this->isDuplicatable($expr->var); + } + if ($expr instanceof Expr\StaticPropertyFetch) { + return true; + } + if ($expr instanceof Expr\ArrayDimFetch) { + return $this->isDuplicatable($expr->var); + } + if ($expr instanceof Expr\ConstFetch) { + return true; + } + if ($expr instanceof Expr\ClassConstFetch) { + return true; + } + if ($expr instanceof Node\Scalar) { + return true; + } + return false; + } +} diff --git a/scripts/php52-downgrader/src/Visitor/StripTypeDeclarationsVisitor.php b/scripts/php52-downgrader/src/Visitor/StripTypeDeclarationsVisitor.php new file mode 100644 index 00000000000..09ded697cc7 --- /dev/null +++ b/scripts/php52-downgrader/src/Visitor/StripTypeDeclarationsVisitor.php @@ -0,0 +1,43 @@ +type = null; + return null; + } + if ($node instanceof Stmt\Function_ || $node instanceof Stmt\ClassMethod) { + $node->returnType = null; + return null; + } + if ($node instanceof Node\Expr\Closure || $node instanceof Node\Expr\ArrowFunction) { + $node->returnType = null; + return null; + } + if ($node instanceof Stmt\Property) { + $node->type = null; + return null; + } + return null; + } +} diff --git a/scripts/php52-downgrader/src/Visitor/VariadicAndSplatVisitor.php b/scripts/php52-downgrader/src/Visitor/VariadicAndSplatVisitor.php new file mode 100644 index 00000000000..362d57da050 --- /dev/null +++ b/scripts/php52-downgrader/src/Visitor/VariadicAndSplatVisitor.php @@ -0,0 +1,225 @@ +rewriteVariadic($node); + } + return null; + } + + public function leaveNode(Node $node) + { + if ($node instanceof Expr\FuncCall) { + return $this->rewriteSplatCall($node); + } + if ($node instanceof Expr\MethodCall) { + return $this->rewriteSplatCall($node); + } + if ($node instanceof Expr\StaticCall) { + return $this->rewriteSplatCall($node); + } + return null; + } + + /** + * Converts trailing `...$rest` parameters into a + * `func_get_args()` slice injected at the start of the body. + */ + private function rewriteVariadic(Node $node): void + { + $params = $node->params ?? []; + $fixed = []; + $variadic = null; + foreach ($params as $p) { + if ($p->variadic) { + $variadic = $p; + continue; + } + $fixed[] = $p; + } + if ($variadic === null) { + return; + } + $node->params = $fixed; + $offset = count($fixed); + $rhs = $offset === 0 + ? new Expr\FuncCall(new Node\Name('func_get_args')) + : new Expr\FuncCall( + new Node\Name('array_slice'), + [ + new Arg(new Expr\FuncCall(new Node\Name('func_get_args'))), + new Arg(new Node\Scalar\Int_($offset)), + ] + ); + $injected = new Stmt\Expression( + new Expr\Assign($variadic->var, $rhs) + ); + if (isset($node->stmts) && $node->stmts !== null) { + array_unshift($node->stmts, $injected); + } + } + + /** + * Rewrites a call expression containing `...$rest` to use + * call_user_func_array. + */ + private function rewriteSplatCall(Node $node): ?Node + { + $args = $node->args; + $splatIdx = null; + foreach ($args as $idx => $arg) { + if ($arg instanceof Arg && $arg->unpack) { + $splatIdx = $idx; + break; + } + } + if ($splatIdx === null) { + return null; + } + // We only handle the simple shape where `...` is the last arg. + // Earlier positions (e.g. `f(...$a, $b)` or `f($a, ...$b, $c)`) + // cannot be expressed via call_user_func_array without an + // intermediate merged argument array and knowledge of all + // subsequent splats — bail out loudly rather than silently + // pass through and emit 5.6+ syntax. + if ($splatIdx !== count($args) - 1) { + $this->throwOnNonTrailingSplat($node, $args); + } + $fixed = array_slice($args, 0, $splatIdx); + /** @var Arg $splatArg */ + $splatArg = $args[$splatIdx]; + $restExpr = $splatArg->value; + + // Build the arg-list expression: array_merge(array(fixed...), $rest). + if (count($fixed) === 0) { + $argsExpr = $restExpr; + } else { + $argsExpr = new Expr\FuncCall( + new Node\Name('array_merge'), + [ + new Arg(new Expr\Array_(array_map( + fn(Arg $a) => new Node\ArrayItem($a->value), + $fixed + ))), + new Arg($restExpr), + ] + ); + } + + $callable = $this->buildCallable($node); + if ($callable === null) { + // Unknown shape (e.g. variable function call). The builder + // would need the raw callable, which we don't have. + // call_user_func_array already accepts whatever the + // original call supports. + return null; + } + return new Expr\FuncCall( + new Node\Name('call_user_func_array'), + [new Arg($callable), new Arg($argsExpr)], + $node->getAttributes() + ); + } + + /** + * @param array $args + * @return never + */ + private function throwOnNonTrailingSplat(Node $node, array $args): void + { + $line = $node->getStartLine(); + $shape = []; + foreach ($args as $a) { + if ($a instanceof Arg && $a->unpack) { + $shape[] = '...'; + } else { + $shape[] = 'arg'; + } + } + throw new \RuntimeException(sprintf( + "VariadicAndSplatVisitor: non-trailing splat arguments are " . + "not supported — saw call with args [%s] at line %d. " . + "Rewrite the source to place `...` last, or pre-merge the " . + "argument list into a single variable.", + implode(', ', $shape), + $line + )); + } + + /** Returns the callable expression (first arg to call_user_func_array). */ + private function buildCallable(Node $node): ?Expr + { + if ($node instanceof Expr\FuncCall) { + if ($node->name instanceof Node\Name) { + return new Node\Scalar\String_($node->name->toString()); + } + if ($node->name instanceof Expr) { + return $node->name; + } + return null; + } + if ($node instanceof Expr\MethodCall) { + $method = $node->name instanceof Node\Identifier + ? $node->name->toString() + : null; + if ($method === null) { + return null; + } + return new Expr\Array_([ + new Node\ArrayItem($node->var), + new Node\ArrayItem(new Node\Scalar\String_($method)), + ]); + } + if ($node instanceof Expr\StaticCall) { + $method = $node->name instanceof Node\Identifier + ? $node->name->toString() + : null; + if ($method === null) { + return null; + } + $cls = $node->class instanceof Node\Name + ? $node->class->toString() + : null; + if ($cls === null) { + return null; + } + return new Expr\Array_([ + new Node\ArrayItem(new Node\Scalar\String_($cls)), + new Node\ArrayItem(new Node\Scalar\String_($method)), + ]); + } + return null; + } +}