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;