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::config::E2eConfig;
8use crate::escape::{escape_php, sanitize_filename};
9use crate::field_access::FieldResolver;
10use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup, HttpFixture, ValidationErrorExpectation};
11use alef_backend_php::naming::php_autoload_namespace;
12use alef_core::backend::GeneratedFile;
13use alef_core::config::ResolvedCrateConfig;
14use alef_core::hash::{self, CommentStyle};
15use alef_core::template_versions as tv;
16use anyhow::Result;
17use heck::{ToLowerCamelCase, ToSnakeCase, ToUpperCamelCase};
18use std::collections::HashMap;
19use std::fmt::Write as FmtWrite;
20use std::path::PathBuf;
21
22use super::E2eCodegen;
23use super::client;
24
25/// PHP e2e code generator.
26pub struct PhpCodegen;
27
28impl E2eCodegen for PhpCodegen {
29    fn generate(
30        &self,
31        groups: &[FixtureGroup],
32        e2e_config: &E2eConfig,
33        config: &ResolvedCrateConfig,
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>/<module>` from the configured repository URL —
76                // alef is vendor-neutral, so we don't fall back to a fixed org.
77                let org = config
78                    .try_github_repo()
79                    .ok()
80                    .as_deref()
81                    .and_then(alef_core::config::derive_repo_org)
82                    .unwrap_or_else(|| config.name.clone());
83                format!("{org}/{}", call.module.replace('_', "-"))
84            });
85        let pkg_path = php_pkg
86            .as_ref()
87            .and_then(|p| p.path.as_ref())
88            .cloned()
89            .unwrap_or_else(|| "../../packages/php".to_string());
90        let pkg_version = php_pkg
91            .as_ref()
92            .and_then(|p| p.version.as_ref())
93            .cloned()
94            .or_else(|| config.resolved_version())
95            .unwrap_or_else(|| "0.1.0".to_string());
96
97        // Derive the e2e composer project metadata from the consumer-binding
98        // pkg_name (`<vendor>/<crate>`) and the configured PHP autoload
99        // namespace — alef is vendor-neutral, so we don't fall back to a
100        // fixed "kreuzberg" string.
101        let e2e_vendor = pkg_name.split('/').next().unwrap_or(&pkg_name).to_string();
102        let e2e_pkg_name = format!("{e2e_vendor}/e2e-php");
103        // PSR-4 autoload keys appear inside a JSON document, so each PHP
104        // namespace separator must be JSON-escaped (`\` → `\\`). The trailing
105        // pair represents the PHP-mandated trailing `\` (which itself escapes
106        // to `\\` in JSON).
107        let php_namespace_escaped = php_autoload_namespace(config).replace('\\', "\\\\");
108        let e2e_autoload_ns = format!("{php_namespace_escaped}\\\\E2e\\\\");
109
110        // Generate composer.json.
111        files.push(GeneratedFile {
112            path: output_base.join("composer.json"),
113            content: render_composer_json(
114                &e2e_pkg_name,
115                &e2e_autoload_ns,
116                &pkg_name,
117                &pkg_path,
118                &pkg_version,
119                e2e_config.dep_mode,
120            ),
121            generated_header: false,
122        });
123
124        // Generate phpunit.xml.
125        files.push(GeneratedFile {
126            path: output_base.join("phpunit.xml"),
127            content: render_phpunit_xml(),
128            generated_header: false,
129        });
130
131        // Check if any fixture is an HTTP test (needs mock server bootstrap).
132        let has_http_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| f.is_http_test());
133
134        // Generate bootstrap.php that loads both autoloaders and optionally starts the mock server.
135        files.push(GeneratedFile {
136            path: output_base.join("bootstrap.php"),
137            content: render_bootstrap(&pkg_path, has_http_fixtures),
138            generated_header: true,
139        });
140
141        // Generate run_tests.php that loads the extension and invokes phpunit.
142        files.push(GeneratedFile {
143            path: output_base.join("run_tests.php"),
144            content: render_run_tests_php(&extension_name, config.php_cargo_crate_name()),
145            generated_header: true,
146        });
147
148        // Generate test files per category.
149        let tests_base = output_base.join("tests");
150        let field_resolver = FieldResolver::new(
151            &e2e_config.fields,
152            &e2e_config.fields_optional,
153            &e2e_config.result_fields,
154            &e2e_config.fields_array,
155            &std::collections::HashSet::new(),
156        );
157
158        for group in groups {
159            let active: Vec<&Fixture> = group
160                .fixtures
161                .iter()
162                .filter(|f| super::should_include_fixture(f, lang, e2e_config))
163                .collect();
164
165            if active.is_empty() {
166                continue;
167            }
168
169            let test_class = format!("{}Test", sanitize_filename(&group.category).to_upper_camel_case());
170            let filename = format!("{test_class}.php");
171            let content = render_test_file(
172                &group.category,
173                &active,
174                e2e_config,
175                lang,
176                &namespace,
177                &class_name,
178                &test_class,
179                &field_resolver,
180                enum_fields,
181                result_is_simple,
182                php_client_factory,
183                options_via,
184            );
185            files.push(GeneratedFile {
186                path: tests_base.join(filename),
187                content,
188                generated_header: true,
189            });
190        }
191
192        Ok(files)
193    }
194
195    fn language_name(&self) -> &'static str {
196        "php"
197    }
198}
199
200// ---------------------------------------------------------------------------
201// Rendering
202// ---------------------------------------------------------------------------
203
204fn render_composer_json(
205    e2e_pkg_name: &str,
206    e2e_autoload_ns: &str,
207    pkg_name: &str,
208    pkg_path: &str,
209    pkg_version: &str,
210    dep_mode: crate::config::DependencyMode,
211) -> String {
212    let (require_section, autoload_section) = match dep_mode {
213        crate::config::DependencyMode::Registry => {
214            let require = format!(
215                r#"  "require": {{
216    "{pkg_name}": "{pkg_version}"
217  }},
218  "require-dev": {{
219    "phpunit/phpunit": "{phpunit}",
220    "guzzlehttp/guzzle": "{guzzle}"
221  }},"#,
222                phpunit = tv::packagist::PHPUNIT,
223                guzzle = tv::packagist::GUZZLE,
224            );
225            (require, String::new())
226        }
227        crate::config::DependencyMode::Local => {
228            let require = format!(
229                r#"  "require-dev": {{
230    "phpunit/phpunit": "{phpunit}",
231    "guzzlehttp/guzzle": "{guzzle}"
232  }},"#,
233                phpunit = tv::packagist::PHPUNIT,
234                guzzle = tv::packagist::GUZZLE,
235            );
236            // For local mode, add autoload for the local package source.
237            // Extract the namespace from pkg_name (org/module) and map it to src/.
238            let pkg_namespace = pkg_name
239                .split('/')
240                .nth(1)
241                .unwrap_or(pkg_name)
242                .split('-')
243                .map(heck::ToUpperCamelCase::to_upper_camel_case)
244                .collect::<Vec<_>>()
245                .join("\\");
246            let autoload = format!(
247                r#"
248  "autoload": {{
249    "psr-4": {{
250      "{}\\": "{}/src/"
251    }}
252  }},"#,
253                pkg_namespace.replace('\\', "\\\\"),
254                pkg_path
255            );
256            (require, autoload)
257        }
258    };
259
260    format!(
261        r#"{{
262  "name": "{e2e_pkg_name}",
263  "description": "E2e tests for PHP bindings",
264  "type": "project",
265{require_section}{autoload_section}
266  "autoload-dev": {{
267    "psr-4": {{
268      "{e2e_autoload_ns}": "tests/"
269    }}
270  }},
271  "scripts": {{
272    "test": "php run_tests.php"
273  }}
274}}
275"#
276    )
277}
278
279fn render_phpunit_xml() -> String {
280    r#"<?xml version="1.0" encoding="UTF-8"?>
281<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
282         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/13.1/phpunit.xsd"
283         bootstrap="bootstrap.php"
284         colors="true"
285         failOnRisky="true"
286         failOnWarning="true">
287    <testsuites>
288        <testsuite name="e2e">
289            <directory>tests</directory>
290        </testsuite>
291    </testsuites>
292</phpunit>
293"#
294    .to_string()
295}
296
297fn render_bootstrap(pkg_path: &str, has_http_fixtures: bool) -> String {
298    let header = hash::header(CommentStyle::DoubleSlash);
299    let mock_server_block = if has_http_fixtures {
300        r#"
301// Spawn the mock HTTP server binary for HTTP fixture tests.
302$mockServerBin = __DIR__ . '/../rust/target/release/mock-server';
303$fixturesDir = __DIR__ . '/../../fixtures';
304if (file_exists($mockServerBin)) {
305    $descriptors = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => STDERR];
306    $proc = proc_open([$mockServerBin, $fixturesDir], $descriptors, $pipes);
307    if (is_resource($proc)) {
308        $line = fgets($pipes[1]);
309        if ($line !== false && str_starts_with($line, 'MOCK_SERVER_URL=')) {
310            putenv(trim($line));
311            $_ENV['MOCK_SERVER_URL'] = trim(substr(trim($line), strlen('MOCK_SERVER_URL=')));
312        }
313        // Drain stdout in background thread is not possible in PHP; keep pipe open.
314        register_shutdown_function(static function () use ($proc, $pipes): void {
315            fclose($pipes[0]);
316            proc_close($proc);
317        });
318    }
319}
320"#
321    } else {
322        ""
323    };
324    format!(
325        r#"<?php
326{header}
327declare(strict_types=1);
328
329// Load the e2e project autoloader (PHPUnit, test helpers).
330require_once __DIR__ . '/vendor/autoload.php';
331
332// Load the PHP binding package classes via its Composer autoloader.
333// The package's autoloader is separate from the e2e project's autoloader
334// since the php-ext type prevents direct composer path dependency.
335$pkgAutoloader = __DIR__ . '/{pkg_path}/vendor/autoload.php';
336if (file_exists($pkgAutoloader)) {{
337    require_once $pkgAutoloader;
338}}
339
340// Change to the test_documents directory so that fixture file paths like
341// "pdf/fake_memo.pdf" resolve correctly when running phpunit from e2e/php/.
342$testDocuments = __DIR__ . '/../../test_documents';
343if (is_dir($testDocuments)) {{
344    chdir($testDocuments);
345}}{mock_server_block}
346"#
347    )
348}
349
350fn render_run_tests_php(extension_name: &str, cargo_crate_name: Option<&str>) -> String {
351    let header = hash::header(CommentStyle::DoubleSlash);
352    let ext_lib_name = if let Some(crate_name) = cargo_crate_name {
353        // Cargo replaces hyphens with underscores for lib names, and the crate name
354        // already includes the _php suffix.
355        format!("lib{}", crate_name.replace('-', "_"))
356    } else {
357        format!("lib{extension_name}_php")
358    };
359    format!(
360        r#"#!/usr/bin/env php
361<?php
362{header}
363declare(strict_types=1);
364
365// Determine platform-specific extension suffix.
366$extSuffix = match (PHP_OS_FAMILY) {{
367    'Darwin' => '.dylib',
368    default => '.so',
369}};
370$extPath = __DIR__ . '/../../target/release/{ext_lib_name}' . $extSuffix;
371
372// If the locally-built extension exists and we have not already restarted with it,
373// re-exec PHP with no system ini (-n) to avoid conflicts with any system-installed
374// version of the extension, then load the local build explicitly.
375if (file_exists($extPath) && !getenv('ALEF_PHP_LOCAL_EXT_LOADED')) {{
376    putenv('ALEF_PHP_LOCAL_EXT_LOADED=1');
377    $php = PHP_BINARY;
378    $phpunitPath = __DIR__ . '/vendor/bin/phpunit';
379
380    $cmd = array_merge(
381        [$php, '-n', '-d', 'extension=' . $extPath],
382        [$phpunitPath],
383        array_slice($GLOBALS['argv'], 1)
384    );
385
386    passthru(implode(' ', array_map('escapeshellarg', $cmd)), $exitCode);
387    exit($exitCode);
388}}
389
390// Extension is now loaded (via the restart above with -n flag).
391// Invoke PHPUnit normally.
392$phpunitPath = __DIR__ . '/vendor/bin/phpunit';
393if (!file_exists($phpunitPath)) {{
394    echo "PHPUnit not found at $phpunitPath. Run 'composer install' first.\n";
395    exit(1);
396}}
397
398require $phpunitPath;
399"#
400    )
401}
402
403#[allow(clippy::too_many_arguments)]
404fn render_test_file(
405    category: &str,
406    fixtures: &[&Fixture],
407    e2e_config: &E2eConfig,
408    lang: &str,
409    namespace: &str,
410    class_name: &str,
411    test_class: &str,
412    field_resolver: &FieldResolver,
413    enum_fields: &HashMap<String, String>,
414    result_is_simple: bool,
415    php_client_factory: Option<&str>,
416    options_via: &str,
417) -> String {
418    let mut out = String::new();
419    let _ = writeln!(out, "<?php");
420    out.push_str(&hash::header(CommentStyle::DoubleSlash));
421    let _ = writeln!(out);
422    let _ = writeln!(out, "declare(strict_types=1);");
423    let _ = writeln!(out);
424    let _ = writeln!(out, "namespace {namespace}\\E2e;");
425    let _ = writeln!(out);
426
427    // Determine if any handle arg has a non-null config (needs CrawlConfig import).
428    let needs_crawl_config_import = fixtures.iter().any(|f| {
429        let call = e2e_config.resolve_call(f.call.as_deref());
430        call.args.iter().filter(|a| a.arg_type == "handle").any(|a| {
431            let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
432            !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
433        })
434    });
435
436    // Determine if any fixture is an HTTP test (needs GuzzleHttp).
437    let has_http_tests = fixtures.iter().any(|f| f.is_http_test());
438
439    // Collect options_type class names that need `use` imports (one import per unique name).
440    // Also pulls in `element_type` declarations from any call's args (e.g. `BatchBytesItem`,
441    // `BatchFileItem` for batch fns) so the test file can reference them by short name.
442    let mut options_type_imports: Vec<String> = fixtures
443        .iter()
444        .flat_map(|f| {
445            let call = e2e_config.resolve_call(f.call.as_deref());
446            let php_override = call.overrides.get(lang);
447            let opt_type = php_override.and_then(|o| o.options_type.as_deref()).or_else(|| {
448                e2e_config
449                    .call
450                    .overrides
451                    .get(lang)
452                    .and_then(|o| o.options_type.as_deref())
453            });
454            let element_types: Vec<String> = call
455                .args
456                .iter()
457                .filter_map(|a| a.element_type.as_ref().map(|t| t.to_string()))
458                .filter(|t| !is_php_reserved_type(t))
459                .collect();
460            opt_type.map(|t| t.to_string()).into_iter().chain(element_types)
461        })
462        .collect::<std::collections::HashSet<_>>()
463        .into_iter()
464        .collect();
465    options_type_imports.sort();
466
467    let _ = writeln!(out, "use PHPUnit\\Framework\\TestCase;");
468    let _ = writeln!(out, "use {namespace}\\{class_name};");
469    if needs_crawl_config_import {
470        let _ = writeln!(out, "use {namespace}\\CrawlConfig;");
471    }
472    for type_name in &options_type_imports {
473        if type_name != class_name {
474            let _ = writeln!(out, "use {namespace}\\{type_name};");
475        }
476    }
477    if has_http_tests {
478        let _ = writeln!(out, "use GuzzleHttp\\Client;");
479    }
480    let _ = writeln!(out);
481    let _ = writeln!(out, "/** E2e tests for category: {category}. */");
482    let _ = writeln!(out, "final class {test_class} extends TestCase");
483    let _ = writeln!(out, "{{");
484
485    // Emit a shared HTTP client property when there are HTTP tests.
486    if has_http_tests {
487        let _ = writeln!(out, "    private Client $httpClient;");
488        let _ = writeln!(out);
489        let _ = writeln!(out, "    protected function setUp(): void");
490        let _ = writeln!(out, "    {{");
491        let _ = writeln!(out, "        parent::setUp();");
492        let _ = writeln!(
493            out,
494            "        $baseUrl = (string)(getenv('MOCK_SERVER_URL') ?: 'http://localhost:8080');"
495        );
496        let _ = writeln!(
497            out,
498            "        $this->httpClient = new Client(['base_uri' => $baseUrl, 'http_errors' => false, 'decode_content' => false, 'allow_redirects' => false]);"
499        );
500        let _ = writeln!(out, "    }}");
501        let _ = writeln!(out);
502    }
503
504    for (i, fixture) in fixtures.iter().enumerate() {
505        if fixture.is_http_test() {
506            render_http_test_method(&mut out, fixture, fixture.http.as_ref().unwrap());
507        } else {
508            render_test_method(
509                &mut out,
510                fixture,
511                e2e_config,
512                lang,
513                namespace,
514                class_name,
515                field_resolver,
516                enum_fields,
517                result_is_simple,
518                php_client_factory,
519                options_via,
520            );
521        }
522        if i + 1 < fixtures.len() {
523            let _ = writeln!(out);
524        }
525    }
526
527    let _ = writeln!(out, "}}");
528    out
529}
530
531// ---------------------------------------------------------------------------
532// HTTP test rendering — shared-driver integration
533// ---------------------------------------------------------------------------
534
535/// Thin renderer that emits PHPUnit test methods targeting a mock server via
536/// Guzzle. Satisfies [`client::TestClientRenderer`] so the shared
537/// [`client::http_call::render_http_test`] driver drives the call sequence.
538struct PhpTestClientRenderer;
539
540impl client::TestClientRenderer for PhpTestClientRenderer {
541    fn language_name(&self) -> &'static str {
542        "php"
543    }
544
545    /// Convert a fixture id to a PHP-valid identifier (snake_case via `sanitize_filename`).
546    fn sanitize_test_name(&self, id: &str) -> String {
547        sanitize_filename(id)
548    }
549
550    /// Emit `/** {description} */ public function test_{fn_name}(): void {`.
551    ///
552    /// When `skip_reason` is `Some`, emits a `markTestSkipped(...)` body and the
553    /// shared driver calls `render_test_close` immediately after, so the closing
554    /// brace is emitted symmetrically.
555    fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
556        let _ = writeln!(out, "    /** {description} */");
557        let _ = writeln!(out, "    public function test_{fn_name}(): void");
558        let _ = writeln!(out, "    {{");
559        if let Some(reason) = skip_reason {
560            let reason_lit = format!("\"{}\"", escape_php(reason));
561            let _ = writeln!(out, "        $this->markTestSkipped({reason_lit});");
562        }
563    }
564
565    /// Emit the closing `}` for a test method.
566    fn render_test_close(&self, out: &mut String) {
567        let _ = writeln!(out, "    }}");
568    }
569
570    /// Emit a Guzzle request to the mock server's `/fixtures/<fixture_id>` endpoint.
571    ///
572    /// The fixture id is extracted from the path (which the mock server routes as
573    /// `/fixtures/<id>`). `$response` is bound for subsequent assertion methods.
574    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
575        let method = ctx.method.to_uppercase();
576
577        // Build Guzzle options array.
578        let mut opts: Vec<String> = Vec::new();
579
580        if let Some(body) = ctx.body {
581            let php_body = json_to_php(body);
582            opts.push(format!("'json' => {php_body}"));
583        }
584
585        // Merge explicit headers and content_type hint.
586        let mut header_pairs: Vec<String> = Vec::new();
587        if let Some(ct) = ctx.content_type {
588            // Only emit if not already in ctx.headers (avoid duplicate Content-Type).
589            if !ctx.headers.keys().any(|k| k.to_lowercase() == "content-type") {
590                header_pairs.push(format!("\"Content-Type\" => \"{}\"", escape_php(ct)));
591            }
592        }
593        for (k, v) in ctx.headers {
594            header_pairs.push(format!("\"{}\" => \"{}\"", escape_php(k), escape_php(v)));
595        }
596        if !header_pairs.is_empty() {
597            opts.push(format!("'headers' => [{}]", header_pairs.join(", ")));
598        }
599
600        if !ctx.cookies.is_empty() {
601            let cookie_str = ctx
602                .cookies
603                .iter()
604                .map(|(k, v)| format!("{}={}", k, v))
605                .collect::<Vec<_>>()
606                .join("; ");
607            opts.push(format!("'headers' => ['Cookie' => \"{}\"]", escape_php(&cookie_str)));
608        }
609
610        if !ctx.query_params.is_empty() {
611            let pairs: Vec<String> = ctx
612                .query_params
613                .iter()
614                .map(|(k, v)| {
615                    let val_str = match v {
616                        serde_json::Value::String(s) => s.clone(),
617                        other => other.to_string(),
618                    };
619                    format!("\"{}\" => \"{}\"", escape_php(k), escape_php(&val_str))
620                })
621                .collect();
622            opts.push(format!("'query' => [{}]", pairs.join(", ")));
623        }
624
625        let path_lit = format!("\"{}\"", escape_php(ctx.path));
626        if opts.is_empty() {
627            let _ = writeln!(
628                out,
629                "        ${} = $this->httpClient->request('{method}', {path_lit});",
630                ctx.response_var,
631            );
632        } else {
633            let _ = writeln!(
634                out,
635                "        ${} = $this->httpClient->request('{method}', {path_lit}, [",
636                ctx.response_var,
637            );
638            for opt in &opts {
639                let _ = writeln!(out, "            {opt},");
640            }
641            let _ = writeln!(out, "        ]);");
642        }
643    }
644
645    /// Emit `$this->assertEquals(status, $response->getStatusCode())`.
646    fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
647        let _ = writeln!(
648            out,
649            "        $this->assertEquals({status}, $response->getStatusCode());"
650        );
651    }
652
653    /// Emit a header assertion using `$response->getHeaderLine(...)` or
654    /// `$response->hasHeader(...)`.
655    ///
656    /// Handles special tokens: `<<present>>`, `<<absent>>`, `<<uuid>>`.
657    fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
658        let header_key = name.to_lowercase();
659        let header_key_lit = format!("\"{}\"", escape_php(&header_key));
660        match expected {
661            "<<present>>" => {
662                let _ = writeln!(
663                    out,
664                    "        $this->assertTrue($response->hasHeader({header_key_lit}));"
665                );
666            }
667            "<<absent>>" => {
668                let _ = writeln!(
669                    out,
670                    "        $this->assertFalse($response->hasHeader({header_key_lit}));"
671                );
672            }
673            "<<uuid>>" => {
674                let _ = writeln!(
675                    out,
676                    "        $this->assertMatchesRegularExpression('/^[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}$/i', $response->getHeaderLine({header_key_lit}));"
677                );
678            }
679            literal => {
680                let val_lit = format!("\"{}\"", escape_php(literal));
681                let _ = writeln!(
682                    out,
683                    "        $this->assertEquals({val_lit}, $response->getHeaderLine({header_key_lit}));"
684                );
685            }
686        }
687    }
688
689    /// Emit a JSON body equality assertion.
690    ///
691    /// Plain string bodies are compared against `(string) $response->getBody()` directly;
692    /// structured bodies (objects, arrays, booleans, numbers) are decoded via `json_decode`
693    /// and compared with `assertEquals`.
694    fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
695        match expected {
696            serde_json::Value::String(s) if !s.is_empty() => {
697                let php_val = format!("\"{}\"", escape_php(s));
698                let _ = writeln!(
699                    out,
700                    "        $this->assertEquals({php_val}, (string) $response->getBody());"
701                );
702            }
703            _ => {
704                let php_val = json_to_php(expected);
705                let _ = writeln!(
706                    out,
707                    "        $body = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);"
708                );
709                let _ = writeln!(out, "        $this->assertEquals({php_val}, $body);");
710            }
711        }
712    }
713
714    /// Emit partial body assertions: one `assertEquals` per field in `expected`.
715    fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
716        if let Some(obj) = expected.as_object() {
717            let _ = writeln!(
718                out,
719                "        $body = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);"
720            );
721            for (key, val) in obj {
722                let php_key = format!("\"{}\"", escape_php(key));
723                let php_val = json_to_php(val);
724                let _ = writeln!(out, "        $this->assertEquals({php_val}, $body[{php_key}]);");
725            }
726        }
727    }
728
729    /// Emit validation-error assertions, checking each expected `msg` against the
730    /// JSON-encoded body string (PHP binding returns ProblemDetails with `errors` array).
731    fn render_assert_validation_errors(
732        &self,
733        out: &mut String,
734        _response_var: &str,
735        errors: &[ValidationErrorExpectation],
736    ) {
737        let _ = writeln!(out, "        $body = json_decode((string) $response->getBody(), true);");
738        for err in errors {
739            let msg_lit = format!("\"{}\"", escape_php(&err.msg));
740            let _ = writeln!(
741                out,
742                "        $this->assertStringContainsString({msg_lit}, json_encode($body, JSON_UNESCAPED_SLASHES));"
743            );
744        }
745    }
746}
747
748/// Render a PHPUnit test method for an HTTP server test fixture via the shared driver.
749///
750/// Handles the one PHP-specific pre-condition: HTTP 101 (WebSocket upgrade) causes
751/// cURL/Guzzle to fail; it is emitted as a `markTestSkipped` stub directly.
752fn render_http_test_method(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
753    // HTTP 101 (WebSocket upgrade) causes cURL to treat the connection as an upgrade
754    // and fail with "empty reply from server". Skip these tests in the PHP e2e suite
755    // since Guzzle cannot assert on WebSocket upgrade responses via regular HTTP.
756    if http.expected_response.status_code == 101 {
757        let method_name = sanitize_filename(&fixture.id);
758        let description = &fixture.description;
759        let _ = writeln!(out, "    /** {description} */");
760        let _ = writeln!(out, "    public function test_{method_name}(): void");
761        let _ = writeln!(out, "    {{");
762        let _ = writeln!(
763            out,
764            "        $this->markTestSkipped('HTTP 101 WebSocket upgrade cannot be tested via Guzzle HTTP client');"
765        );
766        let _ = writeln!(out, "    }}");
767        return;
768    }
769
770    client::http_call::render_http_test(out, &PhpTestClientRenderer, fixture);
771}
772
773// ---------------------------------------------------------------------------
774// Function-call test rendering
775// ---------------------------------------------------------------------------
776
777#[allow(clippy::too_many_arguments)]
778fn render_test_method(
779    out: &mut String,
780    fixture: &Fixture,
781    e2e_config: &E2eConfig,
782    lang: &str,
783    namespace: &str,
784    class_name: &str,
785    field_resolver: &FieldResolver,
786    enum_fields: &HashMap<String, String>,
787    result_is_simple: bool,
788    php_client_factory: Option<&str>,
789    options_via: &str,
790) {
791    // Resolve per-fixture call config: supports named calls via fixture.call field.
792    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
793    let call_overrides = call_config.overrides.get(lang);
794    let has_override = call_overrides.is_some_and(|o| o.function.is_some());
795    let mut function_name = call_overrides
796        .and_then(|o| o.function.as_ref())
797        .cloned()
798        .unwrap_or_else(|| call_config.function.clone());
799    // The internal `KreuzbergApi` class exposes async methods with an `_async` suffix
800    // (`extractBytesAsync`, etc.), but the user-facing `Kreuzberg` facade exposes them
801    // under the bare async-named methods (`extractBytes` is the async one — the sync
802    // version is `extractBytesSync`). The e2e tests target the facade, so don't append
803    // `_async`. (When a language-specific override provides a function name, use it
804    // as-is.)
805    // PHP wrapper classes use lowerCamelCase method names (e.g. getLanguage, downloadAll).
806    // Convert the Rust snake_case name only when no explicit override is provided.
807    if !has_override {
808        function_name = function_name.to_lower_camel_case();
809    }
810    let result_var = &call_config.result_var;
811    let args = &call_config.args;
812
813    let method_name = sanitize_filename(&fixture.id);
814    let description = &fixture.description;
815    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
816
817    // Resolve options_type for this call's PHP override, with fallback to the top-level call override.
818    let call_options_type = call_overrides.and_then(|o| o.options_type.as_deref()).or_else(|| {
819        e2e_config
820            .call
821            .overrides
822            .get(lang)
823            .and_then(|o| o.options_type.as_deref())
824    });
825
826    let (mut setup_lines, args_str) = build_args_and_setup(
827        &fixture.input,
828        args,
829        class_name,
830        enum_fields,
831        &fixture.id,
832        options_via,
833        call_options_type,
834    );
835
836    // Build visitor if present and add to setup
837    let mut options_already_created = !args_str.is_empty() && args_str == "$options";
838    if let Some(visitor_spec) = &fixture.visitor {
839        build_php_visitor(&mut setup_lines, visitor_spec);
840        if !options_already_created {
841            // Create options via builder with visitor.
842            // Note: PHP ext-php-rs bridge limitations mean the visitor() method ignores
843            // its parameter and passes None to the inner builder. This is a known limitation
844            // in the PHP backend that needs a proper visitor bridge implementation.
845            setup_lines.push("$builder = \\HtmlToMarkdown\\ConversionOptions::builder();".to_string());
846            setup_lines.push("$options = $builder->visitor($visitor)->build();".to_string());
847            options_already_created = true;
848        }
849    }
850
851    let final_args = if options_already_created {
852        if args_str.is_empty() || args_str == "$options" {
853            "$options".to_string()
854        } else {
855            format!("{args_str}, $options")
856        }
857    } else {
858        args_str
859    };
860
861    let call_expr = if php_client_factory.is_some() {
862        format!("$client->{function_name}({final_args})")
863    } else {
864        format!("{class_name}::{function_name}({final_args})")
865    };
866
867    let _ = writeln!(out, "    /** {description} */");
868    let _ = writeln!(out, "    public function test_{method_name}(): void");
869    let _ = writeln!(out, "    {{");
870
871    // Honor per-call `skip_languages`: when the call's `skip_languages` includes "php",
872    // the binding doesn't expose the function (e.g. ext-php-rs lacks Vec<#[php_class]>
873    // FromZval support, so batch_extract_* are excluded). Emit a `markTestSkipped`
874    // stub so the test still appears in the report but doesn't try to call.
875    if call_config.skip_languages.iter().any(|l| l == "php") {
876        let _ = writeln!(
877            out,
878            "        $this->markTestSkipped('call {} is skipped for php (skip_languages)');",
879            call_config.function
880        );
881        let _ = writeln!(out, "    }}");
882        let _ = writeln!(out);
883        return;
884    }
885
886    if let Some(factory) = php_client_factory {
887        let _ = writeln!(
888            out,
889            "        $client = \\{namespace}\\{class_name}::{factory}('test-key');"
890        );
891    }
892
893    for line in &setup_lines {
894        let _ = writeln!(out, "        {line}");
895    }
896
897    if expects_error {
898        let _ = writeln!(out, "        $this->expectException(\\Exception::class);");
899        let _ = writeln!(out, "        {call_expr};");
900        let _ = writeln!(out, "    }}");
901        return;
902    }
903
904    // Non-HTTP fixture with no assertions: generate a skipped placeholder so
905    // PHPUnit does not try to call a method that may not exist on the binding.
906    if fixture.assertions.is_empty() {
907        let _ = writeln!(
908            out,
909            "        $this->markTestSkipped('no assertions configured for this fixture in php e2e');"
910        );
911        let _ = writeln!(out, "    }}");
912        return;
913    }
914
915    // If no assertion will actually produce a PHPUnit assert call, mark the test
916    // as intentionally assertion-free so PHPUnit does not flag it as risky.
917    let has_usable = fixture.assertions.iter().any(|a| {
918        if a.assertion_type == "error" || a.assertion_type == "not_error" {
919            return false;
920        }
921        match &a.field {
922            Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
923            _ => true,
924        }
925    });
926    if !has_usable {
927        let _ = writeln!(out, "        $this->expectNotToPerformAssertions();");
928    }
929
930    let _ = writeln!(out, "        ${result_var} = {call_expr};");
931
932    let result_is_array = call_config.result_is_array;
933    for assertion in &fixture.assertions {
934        render_assertion(
935            out,
936            assertion,
937            result_var,
938            field_resolver,
939            result_is_simple,
940            result_is_array,
941        );
942    }
943
944    let _ = writeln!(out, "    }}");
945}
946
947/// Build setup lines (e.g. handle creation) and the argument list for the function call.
948///
949/// `options_via` controls how `json_object` args are passed:
950/// - `"array"` (default): PHP array literal `["key" => value, ...]`
951/// - `"json"`: JSON string via `json_encode([...])` — use when the Rust method accepts `Option<String>`
952///
953/// `options_type` is the PHP class name (e.g. `"ProcessConfig"`) used when constructing options
954/// via `ClassName::from_json(json_encode([...]))`. Required when `options_via` is not `"json"` and
955/// the binding accepts a typed config object.
956///
957/// Returns `(setup_lines, args_string)`.
958/// Emit PHP batch item array constructors for BatchBytesItem or BatchFileItem arrays.
959fn emit_php_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
960    if let Some(items) = arr.as_array() {
961        let item_strs: Vec<String> = items
962            .iter()
963            .filter_map(|item| {
964                if let Some(obj) = item.as_object() {
965                    match elem_type {
966                        "BatchBytesItem" => {
967                            let content = obj.get("content").and_then(|v| v.as_array());
968                            let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
969                            let content_code = if let Some(arr) = content {
970                                let bytes: Vec<String> = arr
971                                    .iter()
972                                    .filter_map(|v| v.as_u64())
973                                    .map(|n| format!("\\x{:02x}", n))
974                                    .collect();
975                                format!("\"{}\"", bytes.join(""))
976                            } else {
977                                "\"\"".to_string()
978                            };
979                            Some(format!(
980                                "new {}(content: {}, mimeType: \"{}\")",
981                                elem_type, content_code, mime_type
982                            ))
983                        }
984                        "BatchFileItem" => {
985                            let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
986                            Some(format!("new {}(path: \"{}\")", elem_type, path))
987                        }
988                        _ => None,
989                    }
990                } else {
991                    None
992                }
993            })
994            .collect();
995        format!("[{}]", item_strs.join(", "))
996    } else {
997        "[]".to_string()
998    }
999}
1000
1001fn build_args_and_setup(
1002    input: &serde_json::Value,
1003    args: &[crate::config::ArgMapping],
1004    class_name: &str,
1005    _enum_fields: &HashMap<String, String>,
1006    fixture_id: &str,
1007    options_via: &str,
1008    options_type: Option<&str>,
1009) -> (Vec<String>, String) {
1010    if args.is_empty() {
1011        // No args configuration: pass the whole input only if it's non-empty.
1012        // Functions with no parameters (e.g. list_models) have empty input and get no args.
1013        let is_empty_input = match input {
1014            serde_json::Value::Null => true,
1015            serde_json::Value::Object(m) => m.is_empty(),
1016            _ => false,
1017        };
1018        if is_empty_input {
1019            return (Vec::new(), String::new());
1020        }
1021        return (Vec::new(), json_to_php(input));
1022    }
1023
1024    let mut setup_lines: Vec<String> = Vec::new();
1025    let mut parts: Vec<String> = Vec::new();
1026
1027    // True when any arg after `from_idx` has a fixture value (or has no fixture
1028    // value but is required — i.e. would emit *something*). Used to decide
1029    // whether a missing optional middle arg must emit `null` to preserve the
1030    // positional argument layout, or can be safely dropped.
1031    let arg_has_emission = |arg: &crate::config::ArgMapping| -> bool {
1032        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1033        let val = input.get(field);
1034        match val {
1035            None | Some(serde_json::Value::Null) => !arg.optional,
1036            Some(_) => true,
1037        }
1038    };
1039    let any_later_has_emission = |from_idx: usize| -> bool { args[from_idx..].iter().any(arg_has_emission) };
1040
1041    for (idx, arg) in args.iter().enumerate() {
1042        if arg.arg_type == "mock_url" {
1043            setup_lines.push(format!(
1044                "${} = getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}';",
1045                arg.name,
1046            ));
1047            parts.push(format!("${}", arg.name));
1048            continue;
1049        }
1050
1051        if arg.arg_type == "handle" {
1052            // Generate a createEngine (or equivalent) call and pass the variable.
1053            let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
1054            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1055            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
1056            if config_value.is_null()
1057                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1058            {
1059                setup_lines.push(format!("${} = {class_name}::{constructor_name}(null);", arg.name,));
1060            } else {
1061                let name = &arg.name;
1062                // Use CrawlConfig::from_json() instead of direct property assignment.
1063                // ext-php-rs doesn't support writable #[php(prop)] fields for complex types,
1064                // so serialize the config to JSON and use from_json() to construct it.
1065                // Filter out empty string enum values before passing to from_json().
1066                let filtered_config = filter_empty_enum_strings(config_value);
1067                setup_lines.push(format!(
1068                    "${name}_config = CrawlConfig::from_json(json_encode({}));",
1069                    json_to_php(&filtered_config)
1070                ));
1071                setup_lines.push(format!(
1072                    "${} = {class_name}::{constructor_name}(${name}_config);",
1073                    arg.name,
1074                ));
1075            }
1076            parts.push(format!("${}", arg.name));
1077            continue;
1078        }
1079
1080        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1081        let val = input.get(field);
1082        match val {
1083            None | Some(serde_json::Value::Null) if arg.arg_type == "json_object" && arg.name == "config" => {
1084                // Special case: ExtractionConfig and similar config objects with no fixture value
1085                // should default to an empty instance (e.g., ExtractionConfig::from_json('{}'))
1086                // to satisfy required parameters. This check happens BEFORE the optional check
1087                // so that config args are always provided, even if marked optional in alef.toml.
1088                // Infer the type name from the arg name and capitalize it (e.g., "config" -> "ExtractionConfig").
1089                let type_name = if arg.name == "config" {
1090                    "ExtractionConfig".to_string()
1091                } else {
1092                    format!("{}Config", arg.name.to_upper_camel_case())
1093                };
1094                parts.push(format!("{type_name}::from_json('{{}}')"));
1095                continue;
1096            }
1097            None | Some(serde_json::Value::Null) if arg.optional => {
1098                // Optional arg with no fixture value. If a later arg WILL emit
1099                // something, we must keep this slot in place by passing `null`
1100                // so the positional argument layout matches the PHP signature.
1101                // Otherwise drop the trailing optional argument entirely.
1102                if any_later_has_emission(idx + 1) {
1103                    parts.push("null".to_string());
1104                }
1105                continue;
1106            }
1107            None | Some(serde_json::Value::Null) => {
1108                // Required arg with no fixture value: pass a language-appropriate default.
1109                let default_val = match arg.arg_type.as_str() {
1110                    "string" => "\"\"".to_string(),
1111                    "int" | "integer" => "0".to_string(),
1112                    "float" | "number" => "0.0".to_string(),
1113                    "bool" | "boolean" => "false".to_string(),
1114                    "json_object" if options_via == "json" => "null".to_string(),
1115                    _ => "null".to_string(),
1116                };
1117                parts.push(default_val);
1118            }
1119            Some(v) => {
1120                if arg.arg_type == "json_object" && !v.is_null() {
1121                    // Check for batch item arrays first
1122                    if let Some(elem_type) = &arg.element_type {
1123                        if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && v.is_array() {
1124                            parts.push(emit_php_batch_item_array(v, elem_type));
1125                            continue;
1126                        }
1127                        // When element_type is a scalar/primitive and value is an array,
1128                        // pass it directly as a PHP array (e.g. ["python"]) rather than
1129                        // wrapping in a typed config constructor.
1130                        if v.is_array() && is_php_reserved_type(elem_type) {
1131                            parts.push(json_to_php(v));
1132                            continue;
1133                        }
1134                    }
1135                    match options_via {
1136                        "json" => {
1137                            // Pass as JSON string via json_encode(); the Rust method accepts Option<String>.
1138                            // Filter out empty string enum values.
1139                            let filtered_v = filter_empty_enum_strings(v);
1140
1141                            // If the config is empty after filtering, pass null instead.
1142                            if let serde_json::Value::Object(obj) = &filtered_v {
1143                                if obj.is_empty() {
1144                                    parts.push("null".to_string());
1145                                    continue;
1146                                }
1147                            }
1148
1149                            parts.push(format!("json_encode({})", json_to_php_camel_keys(&filtered_v)));
1150                            continue;
1151                        }
1152                        _ => {
1153                            if let Some(type_name) = options_type {
1154                                // Use TypeName::from_json(json_encode([...])) to construct the
1155                                // typed config object. ext-php-rs structs expose a from_json()
1156                                // static method that accepts a JSON string.
1157                                // Filter out empty string enum values before passing to from_json().
1158                                let filtered_v = filter_empty_enum_strings(v);
1159
1160                                // For empty objects, construct with from_json('{}') to get the
1161                                // type's defaults rather than passing null (which fails for non-optional params).
1162                                if let serde_json::Value::Object(obj) = &filtered_v {
1163                                    if obj.is_empty() {
1164                                        let arg_var = format!("${}", arg.name);
1165                                        setup_lines.push(format!("{arg_var} = {type_name}::from_json('{{}}');"));
1166                                        parts.push(arg_var);
1167                                        continue;
1168                                    }
1169                                }
1170
1171                                let arg_var = format!("${}", arg.name);
1172                                // Use json_to_php (snake_case) instead of json_to_php_camel_keys because
1173                                // Rust's serde deserializes field names as snake_case by default (via #[serde(rename_all = "snake_case")]).
1174                                // PHP should match Rust field naming conventions, not use camelCase.
1175                                setup_lines.push(format!(
1176                                    "{arg_var} = {type_name}::from_json(json_encode({}));",
1177                                    json_to_php(&filtered_v)
1178                                ));
1179                                parts.push(arg_var);
1180                                continue;
1181                            }
1182                            // Fallback: builder pattern when no options_type is configured.
1183                            // This path is kept for backwards compatibility with projects
1184                            // that use a builder-style API without from_json().
1185                            if let Some(obj) = v.as_object() {
1186                                setup_lines.push("$builder = $this->createDefaultOptionsBuilder();".to_string());
1187                                for (k, vv) in obj {
1188                                    let snake_key = k.to_snake_case();
1189                                    if snake_key == "preprocessing" {
1190                                        if let Some(prep_obj) = vv.as_object() {
1191                                            let enabled =
1192                                                prep_obj.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
1193                                            let preset =
1194                                                prep_obj.get("preset").and_then(|v| v.as_str()).unwrap_or("Minimal");
1195                                            let remove_navigation = prep_obj
1196                                                .get("remove_navigation")
1197                                                .and_then(|v| v.as_bool())
1198                                                .unwrap_or(true);
1199                                            let remove_forms =
1200                                                prep_obj.get("remove_forms").and_then(|v| v.as_bool()).unwrap_or(true);
1201                                            setup_lines.push(format!(
1202                                                "$preprocessing = $this->createPreprocessingOptions({}, {}, {}, {});",
1203                                                if enabled { "true" } else { "false" },
1204                                                json_to_php(&serde_json::Value::String(preset.to_string())),
1205                                                if remove_navigation { "true" } else { "false" },
1206                                                if remove_forms { "true" } else { "false" }
1207                                            ));
1208                                            setup_lines.push(
1209                                                "$builder = $builder->preprocessing($preprocessing);".to_string(),
1210                                            );
1211                                        }
1212                                    }
1213                                }
1214                                setup_lines.push("$options = $builder->build();".to_string());
1215                                parts.push("$options".to_string());
1216                                continue;
1217                            }
1218                        }
1219                    }
1220                }
1221                // For extract_bytes operations, when "data" or "content" field is a string file path,
1222                // wrap it in file_get_contents() to read the actual bytes.
1223                let field_name = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1224                if (field_name == "data" || field_name == "content") && v.is_string() {
1225                    let file_path = v.as_str().unwrap_or("");
1226                    // Check if this looks like a file path (contains / or ends with an extension)
1227                    if file_path.contains('/') || file_path.contains('.') {
1228                        let escaped_path = escape_php(file_path);
1229                        parts.push(format!("file_get_contents(\"{escaped_path}\")"));
1230                        continue;
1231                    }
1232                }
1233                parts.push(json_to_php(v));
1234            }
1235        }
1236    }
1237
1238    (setup_lines, parts.join(", "))
1239}
1240
1241fn render_assertion(
1242    out: &mut String,
1243    assertion: &Assertion,
1244    result_var: &str,
1245    field_resolver: &FieldResolver,
1246    result_is_simple: bool,
1247    result_is_array: bool,
1248) {
1249    // Handle synthetic / derived fields before the is_valid_for_result check
1250    // so they are never treated as struct property accesses on the result.
1251    if let Some(f) = &assertion.field {
1252        match f.as_str() {
1253            "chunks_have_content" => {
1254                let pred = format!(
1255                    "array_reduce(${result_var}->chunks ?? [], fn($carry, $c) => $carry && !empty($c->content), true)"
1256                );
1257                match assertion.assertion_type.as_str() {
1258                    "is_true" => {
1259                        let _ = writeln!(out, "        $this->assertTrue({pred});");
1260                    }
1261                    "is_false" => {
1262                        let _ = writeln!(out, "        $this->assertFalse({pred});");
1263                    }
1264                    _ => {
1265                        let _ = writeln!(
1266                            out,
1267                            "        // skipped: unsupported assertion type on synthetic field '{f}'"
1268                        );
1269                    }
1270                }
1271                return;
1272            }
1273            "chunks_have_embeddings" => {
1274                let pred = format!(
1275                    "array_reduce(${result_var}->chunks ?? [], fn($carry, $c) => $carry && !empty($c->embedding), true)"
1276                );
1277                match assertion.assertion_type.as_str() {
1278                    "is_true" => {
1279                        let _ = writeln!(out, "        $this->assertTrue({pred});");
1280                    }
1281                    "is_false" => {
1282                        let _ = writeln!(out, "        $this->assertFalse({pred});");
1283                    }
1284                    _ => {
1285                        let _ = writeln!(
1286                            out,
1287                            "        // skipped: unsupported assertion type on synthetic field '{f}'"
1288                        );
1289                    }
1290                }
1291                return;
1292            }
1293            // ---- EmbedResponse virtual fields ----
1294            // embed_texts returns array<array<float>> in PHP — no wrapper object.
1295            // $result_var is the embedding matrix; use it directly.
1296            "embeddings" => {
1297                match assertion.assertion_type.as_str() {
1298                    "count_equals" => {
1299                        if let Some(val) = &assertion.value {
1300                            let php_val = json_to_php(val);
1301                            let _ = writeln!(out, "        $this->assertCount({php_val}, ${result_var});");
1302                        }
1303                    }
1304                    "count_min" => {
1305                        if let Some(val) = &assertion.value {
1306                            let php_val = json_to_php(val);
1307                            let _ = writeln!(
1308                                out,
1309                                "        $this->assertGreaterThanOrEqual({php_val}, count(${result_var}));"
1310                            );
1311                        }
1312                    }
1313                    "not_empty" => {
1314                        let _ = writeln!(out, "        $this->assertNotEmpty(${result_var});");
1315                    }
1316                    "is_empty" => {
1317                        let _ = writeln!(out, "        $this->assertEmpty(${result_var});");
1318                    }
1319                    _ => {
1320                        let _ = writeln!(
1321                            out,
1322                            "        // skipped: unsupported assertion type on synthetic field 'embeddings'"
1323                        );
1324                    }
1325                }
1326                return;
1327            }
1328            "embedding_dimensions" => {
1329                let expr = format!("(empty(${result_var}) ? 0 : count(${result_var}[0]))");
1330                match assertion.assertion_type.as_str() {
1331                    "equals" => {
1332                        if let Some(val) = &assertion.value {
1333                            let php_val = json_to_php(val);
1334                            let _ = writeln!(out, "        $this->assertEquals({php_val}, {expr});");
1335                        }
1336                    }
1337                    "greater_than" => {
1338                        if let Some(val) = &assertion.value {
1339                            let php_val = json_to_php(val);
1340                            let _ = writeln!(out, "        $this->assertGreaterThan({php_val}, {expr});");
1341                        }
1342                    }
1343                    _ => {
1344                        let _ = writeln!(
1345                            out,
1346                            "        // skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
1347                        );
1348                    }
1349                }
1350                return;
1351            }
1352            "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1353                let pred = match f.as_str() {
1354                    "embeddings_valid" => {
1355                        format!("array_reduce(${result_var}, fn($carry, $e) => $carry && count($e) > 0, true)")
1356                    }
1357                    "embeddings_finite" => {
1358                        format!(
1359                            "array_reduce(${result_var}, fn($carry, $e) => $carry && array_reduce($e, fn($c, $v) => $c && is_finite($v), true), true)"
1360                        )
1361                    }
1362                    "embeddings_non_zero" => {
1363                        format!(
1364                            "array_reduce(${result_var}, fn($carry, $e) => $carry && count(array_filter($e, fn($v) => $v !== 0.0)) > 0, true)"
1365                        )
1366                    }
1367                    "embeddings_normalized" => {
1368                        format!(
1369                            "array_reduce(${result_var}, fn($carry, $e) => $carry && abs(array_sum(array_map(fn($v) => $v * $v, $e)) - 1.0) < 1e-3, true)"
1370                        )
1371                    }
1372                    _ => unreachable!(),
1373                };
1374                match assertion.assertion_type.as_str() {
1375                    "is_true" => {
1376                        let _ = writeln!(out, "        $this->assertTrue({pred});");
1377                    }
1378                    "is_false" => {
1379                        let _ = writeln!(out, "        $this->assertFalse({pred});");
1380                    }
1381                    _ => {
1382                        let _ = writeln!(
1383                            out,
1384                            "        // skipped: unsupported assertion type on synthetic field '{f}'"
1385                        );
1386                    }
1387                }
1388                return;
1389            }
1390            // ---- keywords / keywords_count ----
1391            // PHP ExtractionResult does not expose extracted_keywords; skip.
1392            "keywords" | "keywords_count" => {
1393                let _ = writeln!(
1394                    out,
1395                    "        // skipped: field '{f}' not available on PHP ExtractionResult"
1396                );
1397                return;
1398            }
1399            _ => {}
1400        }
1401    }
1402
1403    // Skip assertions on fields that don't exist on the result type.
1404    if let Some(f) = &assertion.field {
1405        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1406            let _ = writeln!(out, "        // skipped: field '{f}' not available on result type");
1407            return;
1408        }
1409    }
1410
1411    // When result_is_simple, skip assertions that reference non-content fields
1412    // (e.g., metadata, document, structure) since the binding returns a plain value.
1413    if result_is_simple {
1414        if let Some(f) = &assertion.field {
1415            let f_lower = f.to_lowercase();
1416            if !f.is_empty()
1417                && f_lower != "content"
1418                && (f_lower.starts_with("metadata")
1419                    || f_lower.starts_with("document")
1420                    || f_lower.starts_with("structure"))
1421            {
1422                let _ = writeln!(
1423                    out,
1424                    "        // skipped: result_is_simple, field '{f}' not on simple result type"
1425                );
1426                return;
1427            }
1428        }
1429    }
1430
1431    let field_expr = match &assertion.field {
1432        Some(f) if !f.is_empty() => field_resolver.accessor(f, "php", &format!("${result_var}")),
1433        _ if result_is_simple => {
1434            // When result_is_simple, default to accessing the 'content' field
1435            field_resolver.accessor("content", "php", &format!("${result_var}"))
1436        }
1437        _ => format!("${result_var}"),
1438    };
1439
1440    // For string equality, trim trailing whitespace to handle trailing newlines.
1441    // Only apply trim() when the expected value is a string — calling trim() on int/bool
1442    // throws TypeError in PHP 8.4+.
1443    let trimmed_field_expr_for = |expected: &serde_json::Value| -> String {
1444        if expected.is_string() {
1445            format!("trim({})", field_expr)
1446        } else {
1447            field_expr.clone()
1448        }
1449    };
1450
1451    match assertion.assertion_type.as_str() {
1452        "equals" => {
1453            if let Some(expected) = &assertion.value {
1454                let php_val = json_to_php(expected);
1455                let effective_expr = trimmed_field_expr_for(expected);
1456                let _ = writeln!(out, "        $this->assertEquals({php_val}, {effective_expr});");
1457            }
1458        }
1459        "contains" => {
1460            if let Some(expected) = &assertion.value {
1461                let php_val = json_to_php(expected);
1462                let field_is_array = assertion
1463                    .field
1464                    .as_deref()
1465                    .is_some_and(|f| !f.is_empty() && field_resolver.is_array(f));
1466                if result_is_array && assertion.field.is_none() {
1467                    // Top-level result is an array; use in_array check.
1468                    let _ = writeln!(out, "        $this->assertContains({php_val}, {field_expr});");
1469                } else if field_is_array {
1470                    // Field is an array of objects; check JSON serialization for substring.
1471                    let _ = writeln!(
1472                        out,
1473                        "        $this->assertStringContainsString({php_val}, json_encode({field_expr}));"
1474                    );
1475                } else {
1476                    let _ = writeln!(
1477                        out,
1478                        "        $this->assertStringContainsString({php_val}, {field_expr});"
1479                    );
1480                }
1481            }
1482        }
1483        "contains_all" => {
1484            if let Some(values) = &assertion.values {
1485                let field_is_array = assertion
1486                    .field
1487                    .as_deref()
1488                    .is_some_and(|f| !f.is_empty() && field_resolver.is_array(f));
1489                let effective_expr = if field_is_array {
1490                    format!("json_encode({field_expr})")
1491                } else {
1492                    field_expr.clone()
1493                };
1494                for val in values {
1495                    let php_val = json_to_php(val);
1496                    let _ = writeln!(
1497                        out,
1498                        "        $this->assertStringContainsString({php_val}, {effective_expr});"
1499                    );
1500                }
1501            }
1502        }
1503        "not_contains" => {
1504            if let Some(expected) = &assertion.value {
1505                let php_val = json_to_php(expected);
1506                let _ = writeln!(
1507                    out,
1508                    "        $this->assertStringNotContainsString({php_val}, {field_expr});"
1509                );
1510            }
1511        }
1512        "not_empty" => {
1513            let _ = writeln!(out, "        $this->assertNotEmpty({field_expr});");
1514        }
1515        "is_empty" => {
1516            let _ = writeln!(out, "        $this->assertEmpty({field_expr});");
1517        }
1518        "contains_any" => {
1519            if let Some(values) = &assertion.values {
1520                let _ = writeln!(out, "        $found = false;");
1521                for val in values {
1522                    let php_val = json_to_php(val);
1523                    let _ = writeln!(
1524                        out,
1525                        "        if (str_contains({field_expr}, {php_val})) {{ $found = true; }}"
1526                    );
1527                }
1528                let _ = writeln!(
1529                    out,
1530                    "        $this->assertTrue($found, 'expected to contain at least one of the specified values');"
1531                );
1532            }
1533        }
1534        "greater_than" => {
1535            if let Some(val) = &assertion.value {
1536                let php_val = json_to_php(val);
1537                let _ = writeln!(out, "        $this->assertGreaterThan({php_val}, {field_expr});");
1538            }
1539        }
1540        "less_than" => {
1541            if let Some(val) = &assertion.value {
1542                let php_val = json_to_php(val);
1543                let _ = writeln!(out, "        $this->assertLessThan({php_val}, {field_expr});");
1544            }
1545        }
1546        "greater_than_or_equal" => {
1547            if let Some(val) = &assertion.value {
1548                let php_val = json_to_php(val);
1549                let _ = writeln!(out, "        $this->assertGreaterThanOrEqual({php_val}, {field_expr});");
1550            }
1551        }
1552        "less_than_or_equal" => {
1553            if let Some(val) = &assertion.value {
1554                let php_val = json_to_php(val);
1555                let _ = writeln!(out, "        $this->assertLessThanOrEqual({php_val}, {field_expr});");
1556            }
1557        }
1558        "starts_with" => {
1559            if let Some(expected) = &assertion.value {
1560                let php_val = json_to_php(expected);
1561                let _ = writeln!(out, "        $this->assertStringStartsWith({php_val}, {field_expr});");
1562            }
1563        }
1564        "ends_with" => {
1565            if let Some(expected) = &assertion.value {
1566                let php_val = json_to_php(expected);
1567                let _ = writeln!(out, "        $this->assertStringEndsWith({php_val}, {field_expr});");
1568            }
1569        }
1570        "min_length" => {
1571            if let Some(val) = &assertion.value {
1572                if let Some(n) = val.as_u64() {
1573                    let _ = writeln!(
1574                        out,
1575                        "        $this->assertGreaterThanOrEqual({n}, strlen({field_expr}));"
1576                    );
1577                }
1578            }
1579        }
1580        "max_length" => {
1581            if let Some(val) = &assertion.value {
1582                if let Some(n) = val.as_u64() {
1583                    let _ = writeln!(out, "        $this->assertLessThanOrEqual({n}, strlen({field_expr}));");
1584                }
1585            }
1586        }
1587        "count_min" => {
1588            if let Some(val) = &assertion.value {
1589                if let Some(n) = val.as_u64() {
1590                    let _ = writeln!(
1591                        out,
1592                        "        $this->assertGreaterThanOrEqual({n}, count({field_expr}));"
1593                    );
1594                }
1595            }
1596        }
1597        "count_equals" => {
1598            if let Some(val) = &assertion.value {
1599                if let Some(n) = val.as_u64() {
1600                    let _ = writeln!(out, "        $this->assertCount({n}, {field_expr});");
1601                }
1602            }
1603        }
1604        "is_true" => {
1605            let _ = writeln!(out, "        $this->assertTrue({field_expr});");
1606        }
1607        "is_false" => {
1608            let _ = writeln!(out, "        $this->assertFalse({field_expr});");
1609        }
1610        "method_result" => {
1611            if let Some(method_name) = &assertion.method {
1612                let call_expr = build_php_method_call(result_var, method_name, assertion.args.as_ref());
1613                let check = assertion.check.as_deref().unwrap_or("is_true");
1614                match check {
1615                    "equals" => {
1616                        if let Some(val) = &assertion.value {
1617                            if val.is_boolean() {
1618                                if val.as_bool() == Some(true) {
1619                                    let _ = writeln!(out, "        $this->assertTrue({call_expr});");
1620                                } else {
1621                                    let _ = writeln!(out, "        $this->assertFalse({call_expr});");
1622                                }
1623                            } else {
1624                                let expected = json_to_php(val);
1625                                let _ = writeln!(out, "        $this->assertEquals({expected}, {call_expr});");
1626                            }
1627                        }
1628                    }
1629                    "is_true" => {
1630                        let _ = writeln!(out, "        $this->assertTrue({call_expr});");
1631                    }
1632                    "is_false" => {
1633                        let _ = writeln!(out, "        $this->assertFalse({call_expr});");
1634                    }
1635                    "greater_than_or_equal" => {
1636                        if let Some(val) = &assertion.value {
1637                            let n = val.as_u64().unwrap_or(0);
1638                            let _ = writeln!(out, "        $this->assertGreaterThanOrEqual({n}, {call_expr});");
1639                        }
1640                    }
1641                    "count_min" => {
1642                        if let Some(val) = &assertion.value {
1643                            let n = val.as_u64().unwrap_or(0);
1644                            let _ = writeln!(out, "        $this->assertGreaterThanOrEqual({n}, count({call_expr}));");
1645                        }
1646                    }
1647                    "is_error" => {
1648                        let _ = writeln!(out, "        $this->expectException(\\Exception::class);");
1649                        let _ = writeln!(out, "        {call_expr};");
1650                    }
1651                    "contains" => {
1652                        if let Some(val) = &assertion.value {
1653                            let expected = json_to_php(val);
1654                            let _ = writeln!(
1655                                out,
1656                                "        $this->assertStringContainsString({expected}, {call_expr});"
1657                            );
1658                        }
1659                    }
1660                    other_check => {
1661                        panic!("PHP e2e generator: unsupported method_result check type: {other_check}");
1662                    }
1663                }
1664            } else {
1665                panic!("PHP e2e generator: method_result assertion missing 'method' field");
1666            }
1667        }
1668        "matches_regex" => {
1669            if let Some(expected) = &assertion.value {
1670                let php_val = json_to_php(expected);
1671                let _ = writeln!(
1672                    out,
1673                    "        $this->assertMatchesRegularExpression({php_val}, {field_expr});"
1674                );
1675            }
1676        }
1677        "not_error" => {
1678            // Already handled by the call succeeding without exception.
1679        }
1680        "error" => {
1681            // Handled at the test method level.
1682        }
1683        other => {
1684            panic!("PHP e2e generator: unsupported assertion type: {other}");
1685        }
1686    }
1687}
1688
1689/// Build a PHP call expression for a `method_result` assertion on a tree-sitter `Tree`.
1690///
1691/// Maps method names to the appropriate PHP static function calls on the
1692/// `TreeSitterLanguagePack` class (using the ext-php-rs snake_case method names).
1693fn build_php_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
1694    match method_name {
1695        "root_child_count" => {
1696            format!("count(TreeSitterLanguagePack::named_children_info(${result_var}))")
1697        }
1698        "root_node_type" => {
1699            format!("TreeSitterLanguagePack::root_node_info(${result_var})->kind")
1700        }
1701        "named_children_count" => {
1702            format!("count(TreeSitterLanguagePack::named_children_info(${result_var}))")
1703        }
1704        "has_error_nodes" => {
1705            format!("TreeSitterLanguagePack::tree_has_error_nodes(${result_var})")
1706        }
1707        "error_count" | "tree_error_count" => {
1708            format!("TreeSitterLanguagePack::tree_error_count(${result_var})")
1709        }
1710        "tree_to_sexp" => {
1711            format!("TreeSitterLanguagePack::tree_to_sexp(${result_var})")
1712        }
1713        "contains_node_type" => {
1714            let node_type = args
1715                .and_then(|a| a.get("node_type"))
1716                .and_then(|v| v.as_str())
1717                .unwrap_or("");
1718            format!("TreeSitterLanguagePack::tree_contains_node_type(${result_var}, \"{node_type}\")")
1719        }
1720        "find_nodes_by_type" => {
1721            let node_type = args
1722                .and_then(|a| a.get("node_type"))
1723                .and_then(|v| v.as_str())
1724                .unwrap_or("");
1725            format!("TreeSitterLanguagePack::find_nodes_by_type(${result_var}, \"{node_type}\")")
1726        }
1727        "run_query" => {
1728            let query_source = args
1729                .and_then(|a| a.get("query_source"))
1730                .and_then(|v| v.as_str())
1731                .unwrap_or("");
1732            let language = args
1733                .and_then(|a| a.get("language"))
1734                .and_then(|v| v.as_str())
1735                .unwrap_or("");
1736            format!("TreeSitterLanguagePack::run_query(${result_var}, \"{language}\", \"{query_source}\", $source)")
1737        }
1738        _ => {
1739            format!("${result_var}->{method_name}()")
1740        }
1741    }
1742}
1743
1744/// Filters out empty string enum values from JSON objects before rendering.
1745/// When a field has an empty string value, it's treated as a missing/null enum field
1746/// and should not be included in the PHP array.
1747fn filter_empty_enum_strings(value: &serde_json::Value) -> serde_json::Value {
1748    match value {
1749        serde_json::Value::Object(map) => {
1750            let filtered: serde_json::Map<String, serde_json::Value> = map
1751                .iter()
1752                .filter_map(|(k, v)| {
1753                    // Skip empty string values (typically represent missing enum variants)
1754                    if let serde_json::Value::String(s) = v {
1755                        if s.is_empty() {
1756                            return None;
1757                        }
1758                    }
1759                    // Recursively filter nested objects and arrays
1760                    Some((k.clone(), filter_empty_enum_strings(v)))
1761                })
1762                .collect();
1763            serde_json::Value::Object(filtered)
1764        }
1765        serde_json::Value::Array(arr) => {
1766            let filtered: Vec<serde_json::Value> = arr.iter().map(filter_empty_enum_strings).collect();
1767            serde_json::Value::Array(filtered)
1768        }
1769        other => other.clone(),
1770    }
1771}
1772
1773/// Convert a `serde_json::Value` to a PHP literal string.
1774fn json_to_php(value: &serde_json::Value) -> String {
1775    match value {
1776        serde_json::Value::String(s) => format!("\"{}\"", escape_php(s)),
1777        serde_json::Value::Bool(true) => "true".to_string(),
1778        serde_json::Value::Bool(false) => "false".to_string(),
1779        serde_json::Value::Number(n) => n.to_string(),
1780        serde_json::Value::Null => "null".to_string(),
1781        serde_json::Value::Array(arr) => {
1782            let items: Vec<String> = arr.iter().map(json_to_php).collect();
1783            format!("[{}]", items.join(", "))
1784        }
1785        serde_json::Value::Object(map) => {
1786            let items: Vec<String> = map
1787                .iter()
1788                .map(|(k, v)| format!("\"{}\" => {}", escape_php(k), json_to_php(v)))
1789                .collect();
1790            format!("[{}]", items.join(", "))
1791        }
1792    }
1793}
1794
1795/// Like `json_to_php` but recursively converts all object keys to lowerCamelCase.
1796/// Used when generating PHP option arrays passed to `from_json()` — the PHP binding
1797/// structs use `#[serde(rename_all = "camelCase")]` so snake_case fixture keys
1798/// (e.g. `remove_forms`) must become `removeForms` in the generated test code.
1799fn json_to_php_camel_keys(value: &serde_json::Value) -> String {
1800    match value {
1801        serde_json::Value::Object(map) => {
1802            let items: Vec<String> = map
1803                .iter()
1804                .map(|(k, v)| {
1805                    let camel_key = k.to_lower_camel_case();
1806                    format!("\"{}\" => {}", escape_php(&camel_key), json_to_php_camel_keys(v))
1807                })
1808                .collect();
1809            format!("[{}]", items.join(", "))
1810        }
1811        serde_json::Value::Array(arr) => {
1812            let items: Vec<String> = arr.iter().map(json_to_php_camel_keys).collect();
1813            format!("[{}]", items.join(", "))
1814        }
1815        _ => json_to_php(value),
1816    }
1817}
1818
1819// ---------------------------------------------------------------------------
1820// Visitor generation
1821// ---------------------------------------------------------------------------
1822
1823/// Build a PHP visitor object and add setup lines. The visitor is assigned to $visitor variable.
1824fn build_php_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) {
1825    setup_lines.push("$visitor = new class {".to_string());
1826    for (method_name, action) in &visitor_spec.callbacks {
1827        emit_php_visitor_method(setup_lines, method_name, action);
1828    }
1829    setup_lines.push("};".to_string());
1830}
1831
1832/// Emit a PHP visitor method for a callback action.
1833fn emit_php_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
1834    let snake_method = method_name;
1835    let params = match method_name {
1836        "visit_link" => "$ctx, $href, $text, $title",
1837        "visit_image" => "$ctx, $src, $alt, $title",
1838        "visit_heading" => "$ctx, $level, $text, $id",
1839        "visit_code_block" => "$ctx, $lang, $code",
1840        "visit_code_inline"
1841        | "visit_strong"
1842        | "visit_emphasis"
1843        | "visit_strikethrough"
1844        | "visit_underline"
1845        | "visit_subscript"
1846        | "visit_superscript"
1847        | "visit_mark"
1848        | "visit_button"
1849        | "visit_summary"
1850        | "visit_figcaption"
1851        | "visit_definition_term"
1852        | "visit_definition_description" => "$ctx, $text",
1853        "visit_text" => "$ctx, $text",
1854        "visit_list_item" => "$ctx, $ordered, $marker, $text",
1855        "visit_blockquote" => "$ctx, $content, $depth",
1856        "visit_table_row" => "$ctx, $cells, $isHeader",
1857        "visit_custom_element" => "$ctx, $tagName, $html",
1858        "visit_form" => "$ctx, $actionUrl, $method",
1859        "visit_input" => "$ctx, $input_type, $name, $value",
1860        "visit_audio" | "visit_video" | "visit_iframe" => "$ctx, $src",
1861        "visit_details" => "$ctx, $isOpen",
1862        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "$ctx, $output",
1863        "visit_list_start" => "$ctx, $ordered",
1864        "visit_list_end" => "$ctx, $ordered, $output",
1865        _ => "$ctx",
1866    };
1867
1868    setup_lines.push(format!("    public function {snake_method}({params}) {{"));
1869    match action {
1870        CallbackAction::Skip => {
1871            setup_lines.push("        return 'skip';".to_string());
1872        }
1873        CallbackAction::Continue => {
1874            setup_lines.push("        return 'continue';".to_string());
1875        }
1876        CallbackAction::PreserveHtml => {
1877            setup_lines.push("        return 'preserve_html';".to_string());
1878        }
1879        CallbackAction::Custom { output } => {
1880            let escaped = escape_php(output);
1881            setup_lines.push(format!("        return ['custom' => \"{escaped}\"];"));
1882        }
1883        CallbackAction::CustomTemplate { template } => {
1884            let escaped = escape_php(template);
1885            // Replace {key} placeholders with {$key} for PHP variable interpolation in double-quoted strings.
1886            let mut interpolated = String::new();
1887            let mut chars = escaped.chars().peekable();
1888            while let Some(ch) = chars.next() {
1889                if ch == '{' {
1890                    // Check if next char is a letter or underscore (start of identifier)
1891                    if let Some(&next_ch) = chars.peek() {
1892                        if next_ch.is_ascii_alphabetic() || next_ch == '_' {
1893                            // Consume identifier and find closing brace
1894                            interpolated.push('{');
1895                            interpolated.push('$');
1896                            while let Some(&c) = chars.peek() {
1897                                if c.is_ascii_alphanumeric() || c == '_' {
1898                                    interpolated.push(chars.next().unwrap());
1899                                } else if c == '}' {
1900                                    interpolated.push(chars.next().unwrap());
1901                                    break;
1902                                } else {
1903                                    // Not a valid identifier continuation; emit literally and stop
1904                                    interpolated.push('{');
1905                                    break;
1906                                }
1907                            }
1908                            continue;
1909                        }
1910                    }
1911                }
1912                interpolated.push(ch);
1913            }
1914            setup_lines.push(format!("        return ['custom' => \"{interpolated}\"];"));
1915        }
1916    }
1917    setup_lines.push("    }".to_string());
1918}
1919
1920/// Returns true if the type name is a PHP reserved/primitive type that cannot be imported.
1921fn is_php_reserved_type(name: &str) -> bool {
1922    matches!(
1923        name.to_ascii_lowercase().as_str(),
1924        "string"
1925            | "int"
1926            | "integer"
1927            | "float"
1928            | "double"
1929            | "bool"
1930            | "boolean"
1931            | "array"
1932            | "object"
1933            | "null"
1934            | "void"
1935            | "callable"
1936            | "iterable"
1937            | "never"
1938            | "self"
1939            | "parent"
1940            | "static"
1941            | "true"
1942            | "false"
1943            | "mixed"
1944    )
1945}