Skip to main content

alef/e2e/codegen/
php.rs

1//! PHP e2e test generator using PHPUnit.
2//!
3//! Generates `e2e/php/composer.json`, `e2e/php/phpunit.xml`, and
4//! `tests/{Category}Test.php` files from JSON fixtures, driven entirely by
5//! `E2eConfig` and `CallConfig`.
6
7use crate::backends::php::naming::php_autoload_namespace;
8use crate::core::backend::GeneratedFile;
9use crate::core::config::Language;
10use crate::core::config::ResolvedCrateConfig;
11use crate::e2e::config::E2eConfig;
12use crate::e2e::escape::sanitize_filename;
13use crate::e2e::fixture::{Fixture, FixtureGroup};
14use anyhow::Result;
15use heck::ToUpperCamelCase;
16use std::collections::{HashMap, HashSet};
17use std::path::PathBuf;
18
19use super::E2eCodegen;
20
21/// PHP e2e code generator.
22pub struct PhpCodegen;
23
24impl E2eCodegen for PhpCodegen {
25    fn generate(
26        &self,
27        groups: &[FixtureGroup],
28        e2e_config: &E2eConfig,
29        config: &ResolvedCrateConfig,
30        type_defs: &[crate::core::ir::TypeDef],
31        enums: &[crate::core::ir::EnumDef],
32        _functions: &[crate::core::ir::FunctionDef],
33        errors: &[crate::core::ir::ErrorDef],
34    ) -> Result<Vec<GeneratedFile>> {
35        let lang = self.language_name();
36        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
37
38        let mut files = Vec::new();
39
40        // Resolve top-level call config to derive class/namespace/factory — these are
41        // shared across all categories. Per-fixture call routing (function name, args)
42        // is resolved inside render_test_method via e2e_config.resolve_call().
43        let call = &e2e_config.call;
44        let overrides = call.overrides.get(lang);
45        let extension_name = config.php_extension_name();
46        let class_name = overrides
47            .and_then(|o| o.class.as_ref())
48            .cloned()
49            .map(|cn| cn.split('\\').next_back().unwrap_or(&cn).to_string())
50            .unwrap_or_else(|| extension_name.to_upper_camel_case());
51        let namespace = overrides.and_then(|o| o.module.as_ref()).cloned().unwrap_or_else(|| {
52            if extension_name.contains('_') {
53                extension_name
54                    .split('_')
55                    .map(|p| p.to_upper_camel_case())
56                    .collect::<Vec<_>>()
57                    .join("\\")
58            } else {
59                extension_name.to_upper_camel_case()
60            }
61        });
62        let empty_enum_fields = HashMap::new();
63        let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
64        let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
65        let php_client_factory = overrides.and_then(|o| o.php_client_factory.as_deref());
66        let options_via = overrides.and_then(|o| o.options_via.as_deref()).unwrap_or("array");
67
68        // Resolve package config.
69        let php_pkg = e2e_config.resolve_package("php");
70        let pkg_name = php_pkg
71            .as_ref()
72            .and_then(|p| p.name.as_ref())
73            .cloned()
74            .unwrap_or_else(|| {
75                // Derive `<org>/<package>` for Packagist from the configured repository URL.
76                // The Packagist package name is typically based on call.module (not the Rust
77                // crate name), which may include `-rs` for FFI crates. For PHP (which uses
78                // the pure Packagist name without language suffixes), strip `-rs` if present.
79                let org = config
80                    .try_github_repo()
81                    .ok()
82                    .as_deref()
83                    .and_then(crate::core::config::derive_repo_org)
84                    .unwrap_or_else(|| config.name.clone());
85                let mut pkg_module = call.module.replace('_', "-");
86                // Strip Rust FFI crate suffix for Packagist package naming convention.
87                if pkg_module.ends_with("-rs") {
88                    pkg_module = pkg_module[..pkg_module.len() - 3].to_string();
89                }
90                format!("{org}/{pkg_module}")
91            });
92        let pkg_path = php_pkg
93            .as_ref()
94            .and_then(|p| p.path.as_ref())
95            .cloned()
96            .unwrap_or_else(|| default_php_pkg_path(config));
97        let pkg_version = php_pkg
98            .as_ref()
99            .and_then(|p| p.version.as_ref())
100            .cloned()
101            .or_else(|| config.resolved_version())
102            .unwrap_or_else(|| "0.1.0".to_string());
103
104        // Derive the e2e composer project metadata from the consumer-binding
105        // pkg_name (`<vendor>/<crate>`) and the configured PHP autoload
106        // namespace — alef is vendor-neutral, so we don't fall back to a
107        // fixed "sample_core" string.
108        let e2e_vendor = pkg_name.split('/').next().unwrap_or(&pkg_name).to_string();
109        let e2e_pkg_name = format!("{e2e_vendor}/e2e-php");
110        // PSR-4 autoload keys appear inside a JSON document, so each PHP
111        // namespace separator must be JSON-escaped (`\` → `\\`). The trailing
112        // pair represents the PHP-mandated trailing `\` (which itself escapes
113        // to `\\` in JSON).
114        let php_namespace = php_autoload_namespace(config);
115        let php_namespace_escaped = php_namespace.replace('\\', "\\\\");
116        let e2e_autoload_ns = format!("{php_namespace_escaped}\\\\E2e\\\\");
117
118        // Generate composer.json.
119        files.push(GeneratedFile {
120            path: output_base.join("composer.json"),
121            content: project::render_composer_json(
122                &e2e_pkg_name,
123                &e2e_autoload_ns,
124                &extension_name,
125                &php_namespace,
126                &pkg_path,
127                &pkg_version,
128                e2e_config.dep_mode,
129            ),
130            generated_header: false,
131        });
132
133        // Generate install.sh (registry mode only) — bootstraps PIE and installs
134        // the extension before `composer install` runs in the verify-install flow.
135        // The pinned version is baked in at generate time so callers can run
136        // `bash install.sh` with no args.
137        if e2e_config.dep_mode == crate::e2e::config::DependencyMode::Registry {
138            files.push(GeneratedFile {
139                path: output_base.join("install.sh"),
140                content: project::render_install_sh(&pkg_name, &extension_name, &pkg_version),
141                generated_header: false,
142            });
143        }
144
145        // `generated_header: true` so `ensure_generated_header` actually runs and stamps
146        // provenance. Without it poly cannot recognise the file as generated, lints it as
147        // hand-written source, and then alef and poly fight over its bytes on every regen.
148        // The extension table was NOT the cause here — `marker_header_syntax` handles `.xml`
149        // (marker on line 1, after the `<?xml ?>` declaration); this callsite simply never
150        // asked for a header. Safe to flip specifically because e2e/test_apps files are
151        // written with `overwrite = true`, so `can_skip` is already false and this controls
152        // nothing but the header. Do NOT copy the flip to a `packages/**` file on that
153        // reasoning — those are create-once and flipping it there can freeze them. ~keep
154        files.push(GeneratedFile {
155            path: output_base.join("phpunit.xml"),
156            content: project::render_phpunit_xml(),
157            generated_header: true,
158        });
159
160        // Check if any fixture needs a mock HTTP server (either http-shape or
161        // demo-client mock_response-shape) so bootstrap.php spawns it.
162        let has_mock_server_fixtures = groups
163            .iter()
164            .flat_map(|g| g.fixtures.iter())
165            .any(|f| f.needs_mock_server());
166
167        // Check if any fixture uses HTTP server-pattern (has http field and harness config).
168        let has_http_server_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| f.http.is_some());
169        let uses_server_harness = has_http_server_fixtures && !e2e_config.harness.imports.is_empty();
170
171        // Check if any fixture uses file_path or bytes args (needs chdir to test_documents).
172        let has_file_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| {
173            let cc = e2e_config.resolve_call_for_fixture(
174                f.call.as_deref(),
175                &f.id,
176                &f.resolved_category(),
177                &f.tags,
178                &f.input,
179            );
180            cc.args
181                .iter()
182                .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
183        });
184
185        // app_harness.php is now emitted by a consumer extension.
186
187        // Generate bootstrap.php that loads both autoloaders and optionally starts the mock server.
188        files.push(GeneratedFile {
189            path: output_base.join("bootstrap.php"),
190            content: project::render_bootstrap(project::BootstrapOptions {
191                e2e_config,
192                pkg_path: &pkg_path,
193                has_mock_server_fixtures,
194                has_file_fixtures,
195                test_documents_path: &e2e_config.test_documents_relative_from(0),
196                uses_server_harness,
197                harness_host: &e2e_config.harness.host,
198                harness_port: e2e_config.harness.port,
199            }),
200            generated_header: true,
201        });
202
203        // Generate run_tests.php that loads the extension and invokes phpunit.
204        files.push(GeneratedFile {
205            path: output_base.join("run_tests.php"),
206            content: project::render_run_tests_php(&extension_name, config.php_cargo_crate_name()),
207            generated_header: true,
208        });
209
210        // Generate test files per category.
211        let tests_base = output_base.join("tests");
212
213        // Compute per-(type, field) getter classification for PHP.
214        // ext-php-rs 0.15.x exposes scalar fields as PHP properties via `#[php(prop)]`,
215        // but non-scalar fields (Named structs, Vec<Named>, Map, etc.) need a
216        // `#[php(getter)]` method because `get_method_props` is unimplemented in
217        // ext-php-rs-derive 0.11.7. E2e assertions must call `->getCamelCase()` for those.
218        //
219        // The classification MUST be keyed by (owner_type, field_name) rather than
220        // bare field_name: two unrelated types can declare the same field name with
221        // different scalarness (e.g. `CrawlConfig.content: ContentConfig` vs
222        // `MarkdownResult.content: String`). A bare-name union would force every
223        // `->content` access to `->getContent()` even on types where it is a scalar
224        // property. This covers DTOs where `getContent()` is a true accessor
225        // without forcing getter syntax for scalar fields where the method does
226        // not exist.
227        let php_enum_names: HashSet<String> = enums.iter().map(|e| e.name.clone()).collect();
228
229        for group in groups {
230            let active: Vec<&Fixture> = group
231                .fixtures
232                .iter()
233                .filter(|f| super::should_include_fixture(f, lang, e2e_config))
234                .collect();
235
236            if active.is_empty() {
237                continue;
238            }
239
240            let test_class = format!("{}Test", sanitize_filename(&group.category).to_upper_camel_case());
241            let filename = format!("{test_class}.php");
242            let php_lang_rename_all = config.serde_rename_all_for_language(crate::core::config::Language::Php);
243            let content = test_file::render_test_file(
244                &group.category,
245                &active,
246                e2e_config,
247                lang,
248                &namespace,
249                &class_name,
250                &test_class,
251                type_defs,
252                &php_enum_names,
253                enum_fields,
254                result_is_simple,
255                php_client_factory,
256                options_via,
257                &config.adapters,
258                php_lang_rename_all,
259                config,
260                errors,
261            );
262            files.push(GeneratedFile {
263                path: tests_base.join(filename),
264                content,
265                generated_header: true,
266            });
267        }
268
269        Ok(files)
270    }
271
272    fn language_name(&self) -> &'static str {
273        "php"
274    }
275
276    fn render_snippet_body(
277        &self,
278        fixture: &Fixture,
279        e2e_config: &E2eConfig,
280        config: &ResolvedCrateConfig,
281        type_defs: &[crate::core::ir::TypeDef],
282        enums: &[crate::core::ir::EnumDef],
283    ) -> Result<String> {
284        snippet::render_snippet_body(fixture, e2e_config, config, type_defs, enums)
285    }
286}
287
288/// Default `path` for the local PHP composer dependency when
289/// `[crates.e2e.packages.php].path` is unset.
290///
291/// Derived from [`ResolvedCrateConfig::package_dir`] for [`Language::Php`], which
292/// follows `[crates.output] php` when configured — since 0.51 that co-locates the
293/// generated userland classes with the PHP binding crate at `crates/<pkg>-php/src/`
294/// instead of the historical `packages/php/` — or falls back to the historical
295/// `packages/php` default when unconfigured.
296///
297/// `php_autoload_section` (in `project.rs`) always appends its own `/src/` suffix to
298/// this path to build the PSR-4 mapping, mirroring the historical split layout
299/// (`packages/php/composer.json` + `packages/php/src/*.php`). When the resolved
300/// package directory is co-located and already ends in `/src` (the
301/// `crates/<pkg>-php/src/` shape), that trailing segment is stripped here so the
302/// re-appended `/src/` lands back on the real directory instead of doubling up into a
303/// nonexistent `.../src/src/`. ~keep
304fn default_php_pkg_path(config: &ResolvedCrateConfig) -> String {
305    let pkg_dir = config.package_dir(Language::Php);
306    let trimmed = pkg_dir.trim_end_matches('/');
307    if trimmed.is_empty() {
308        return "../../packages/php".to_string();
309    }
310    let crate_root = trimmed.strip_suffix("/src").unwrap_or(trimmed);
311    if crate_root.is_empty() {
312        return "../../packages/php".to_string();
313    }
314    format!("../../{crate_root}")
315}
316
317mod args;
318mod assertions;
319mod http;
320mod project;
321mod snippet;
322mod stubs;
323mod test_file;
324mod test_method;
325mod types;
326mod values;
327mod visitor;
328
329pub use stubs::{emit_test_backend, emit_test_backend_with_ns};
330
331#[cfg(test)]
332mod tests;