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