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::{
11    Assertion, CallbackAction, Fixture, FixtureGroup, HttpExpectedResponse, HttpFixture, HttpRequest,
12};
13use alef_core::backend::GeneratedFile;
14use alef_core::config::AlefConfig;
15use alef_core::hash::{self, CommentStyle};
16use alef_core::template_versions as tv;
17use anyhow::Result;
18use heck::{ToSnakeCase, ToUpperCamelCase};
19use std::collections::HashMap;
20use std::fmt::Write as FmtWrite;
21use std::path::PathBuf;
22
23use super::E2eCodegen;
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        alef_config: &AlefConfig,
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 = alef_config.php_extension_name();
46        let class_name = overrides
47            .and_then(|o| o.class.as_ref())
48            .cloned()
49            .unwrap_or_else(|| extension_name.to_upper_camel_case());
50        let namespace = overrides.and_then(|o| o.module.as_ref()).cloned().unwrap_or_else(|| {
51            if extension_name.contains('_') {
52                extension_name
53                    .split('_')
54                    .map(|p| p.to_upper_camel_case())
55                    .collect::<Vec<_>>()
56                    .join("\\")
57            } else {
58                extension_name.to_upper_camel_case()
59            }
60        });
61        let empty_enum_fields = HashMap::new();
62        let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
63        let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
64        let php_client_factory = overrides.and_then(|o| o.php_client_factory.as_deref());
65        let options_via = overrides.and_then(|o| o.options_via.as_deref()).unwrap_or("array");
66
67        // Resolve package config.
68        let php_pkg = e2e_config.resolve_package("php");
69        let pkg_name = php_pkg
70            .as_ref()
71            .and_then(|p| p.name.as_ref())
72            .cloned()
73            .unwrap_or_else(|| format!("kreuzberg/{}", call.module.replace('_', "-")));
74        let pkg_path = php_pkg
75            .as_ref()
76            .and_then(|p| p.path.as_ref())
77            .cloned()
78            .unwrap_or_else(|| "../../packages/php".to_string());
79        let pkg_version = php_pkg
80            .as_ref()
81            .and_then(|p| p.version.as_ref())
82            .cloned()
83            .unwrap_or_else(|| "0.1.0".to_string());
84
85        // Generate composer.json.
86        files.push(GeneratedFile {
87            path: output_base.join("composer.json"),
88            content: render_composer_json(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
89            generated_header: false,
90        });
91
92        // Generate phpunit.xml.
93        files.push(GeneratedFile {
94            path: output_base.join("phpunit.xml"),
95            content: render_phpunit_xml(),
96            generated_header: false,
97        });
98
99        // Generate bootstrap.php that loads both autoloaders.
100        files.push(GeneratedFile {
101            path: output_base.join("bootstrap.php"),
102            content: render_bootstrap(&pkg_path),
103            generated_header: true,
104        });
105
106        // Generate test files per category.
107        let tests_base = output_base.join("tests");
108        let field_resolver = FieldResolver::new(
109            &e2e_config.fields,
110            &e2e_config.fields_optional,
111            &e2e_config.result_fields,
112            &e2e_config.fields_array,
113        );
114
115        for group in groups {
116            let active: Vec<&Fixture> = group
117                .fixtures
118                .iter()
119                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
120                .collect();
121
122            if active.is_empty() {
123                continue;
124            }
125
126            let test_class = format!("{}Test", sanitize_filename(&group.category).to_upper_camel_case());
127            let filename = format!("{test_class}.php");
128            let content = render_test_file(
129                &group.category,
130                &active,
131                e2e_config,
132                lang,
133                &namespace,
134                &class_name,
135                &test_class,
136                &field_resolver,
137                enum_fields,
138                result_is_simple,
139                php_client_factory,
140                options_via,
141            );
142            files.push(GeneratedFile {
143                path: tests_base.join(filename),
144                content,
145                generated_header: true,
146            });
147        }
148
149        Ok(files)
150    }
151
152    fn language_name(&self) -> &'static str {
153        "php"
154    }
155}
156
157// ---------------------------------------------------------------------------
158// Rendering
159// ---------------------------------------------------------------------------
160
161fn render_composer_json(
162    pkg_name: &str,
163    _pkg_path: &str,
164    pkg_version: &str,
165    dep_mode: crate::config::DependencyMode,
166) -> String {
167    let require_section = match dep_mode {
168        crate::config::DependencyMode::Registry => {
169            format!(
170                r#"  "require": {{
171    "{pkg_name}": "{pkg_version}"
172  }},
173  "require-dev": {{
174    "phpunit/phpunit": "{phpunit}",
175    "guzzlehttp/guzzle": "{guzzle}"
176  }},"#,
177                phpunit = tv::packagist::PHPUNIT,
178                guzzle = tv::packagist::GUZZLE,
179            )
180        }
181        crate::config::DependencyMode::Local => format!(
182            r#"  "require-dev": {{
183    "phpunit/phpunit": "{phpunit}",
184    "guzzlehttp/guzzle": "{guzzle}"
185  }},"#,
186            phpunit = tv::packagist::PHPUNIT,
187            guzzle = tv::packagist::GUZZLE,
188        ),
189    };
190
191    format!(
192        r#"{{
193  "name": "kreuzberg/e2e-php",
194  "description": "E2e tests for PHP bindings",
195  "type": "project",
196{require_section}
197  "autoload-dev": {{
198    "psr-4": {{
199      "Kreuzberg\\E2e\\": "tests/"
200    }}
201  }}
202}}
203"#
204    )
205}
206
207fn render_phpunit_xml() -> String {
208    r#"<?xml version="1.0" encoding="UTF-8"?>
209<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
210         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/13.1/phpunit.xsd"
211         bootstrap="bootstrap.php"
212         colors="true"
213         failOnRisky="true"
214         failOnWarning="true">
215    <testsuites>
216        <testsuite name="e2e">
217            <directory>tests</directory>
218        </testsuite>
219    </testsuites>
220</phpunit>
221"#
222    .to_string()
223}
224
225fn render_bootstrap(pkg_path: &str) -> String {
226    let header = hash::header(CommentStyle::DoubleSlash);
227    format!(
228        r#"<?php
229{header}
230declare(strict_types=1);
231
232// Load the e2e project autoloader (PHPUnit, test helpers).
233require_once __DIR__ . '/vendor/autoload.php';
234
235// Load the PHP binding package classes via its Composer autoloader.
236// The package's autoloader is separate from the e2e project's autoloader
237// since the php-ext type prevents direct composer path dependency.
238$pkgAutoloader = __DIR__ . '/{pkg_path}/vendor/autoload.php';
239if (file_exists($pkgAutoloader)) {{
240    require_once $pkgAutoloader;
241}}
242"#
243    )
244}
245
246#[allow(clippy::too_many_arguments)]
247fn render_test_file(
248    category: &str,
249    fixtures: &[&Fixture],
250    e2e_config: &E2eConfig,
251    lang: &str,
252    namespace: &str,
253    class_name: &str,
254    test_class: &str,
255    field_resolver: &FieldResolver,
256    enum_fields: &HashMap<String, String>,
257    result_is_simple: bool,
258    php_client_factory: Option<&str>,
259    options_via: &str,
260) -> String {
261    let mut out = String::new();
262    let _ = writeln!(out, "<?php");
263    out.push_str(&hash::header(CommentStyle::DoubleSlash));
264    let _ = writeln!(out);
265    let _ = writeln!(out, "declare(strict_types=1);");
266    let _ = writeln!(out);
267    let _ = writeln!(out, "namespace Kreuzberg\\E2e;");
268    let _ = writeln!(out);
269
270    // Determine if any handle arg has a non-null config (needs CrawlConfig import).
271    let needs_crawl_config_import = fixtures.iter().any(|f| {
272        let call = e2e_config.resolve_call(f.call.as_deref());
273        call.args.iter().filter(|a| a.arg_type == "handle").any(|a| {
274            let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
275            !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
276        })
277    });
278
279    // Determine if any fixture is an HTTP test (needs GuzzleHttp).
280    let has_http_tests = fixtures.iter().any(|f| f.is_http_test());
281
282    let _ = writeln!(out, "use PHPUnit\\Framework\\TestCase;");
283    let _ = writeln!(out, "use {namespace}\\{class_name};");
284    if needs_crawl_config_import {
285        let _ = writeln!(out, "use {namespace}\\CrawlConfig;");
286    }
287    if has_http_tests {
288        let _ = writeln!(out, "use GuzzleHttp\\Client;");
289    }
290    let _ = writeln!(out);
291    let _ = writeln!(out, "/** E2e tests for category: {category}. */");
292    let _ = writeln!(out, "final class {test_class} extends TestCase");
293    let _ = writeln!(out, "{{");
294
295    // Emit a shared HTTP client property when there are HTTP tests.
296    if has_http_tests {
297        let _ = writeln!(out, "    private Client $httpClient;");
298        let _ = writeln!(out);
299        let _ = writeln!(out, "    protected function setUp(): void");
300        let _ = writeln!(out, "    {{");
301        let _ = writeln!(out, "        parent::setUp();");
302        let _ = writeln!(
303            out,
304            "        $baseUrl = getenv('TEST_SERVER_URL') ?: 'http://localhost:8080';"
305        );
306        let _ = writeln!(
307            out,
308            "        $this->httpClient = new Client(['base_uri' => $baseUrl, 'http_errors' => false]);"
309        );
310        let _ = writeln!(out, "    }}");
311        let _ = writeln!(out);
312    }
313
314    for (i, fixture) in fixtures.iter().enumerate() {
315        if fixture.is_http_test() {
316            render_http_test_method(&mut out, fixture, fixture.http.as_ref().unwrap());
317        } else {
318            render_test_method(
319                &mut out,
320                fixture,
321                e2e_config,
322                lang,
323                namespace,
324                class_name,
325                field_resolver,
326                enum_fields,
327                result_is_simple,
328                php_client_factory,
329                options_via,
330            );
331        }
332        if i + 1 < fixtures.len() {
333            let _ = writeln!(out);
334        }
335    }
336
337    let _ = writeln!(out, "}}");
338    out
339}
340
341// ---------------------------------------------------------------------------
342// HTTP test rendering
343// ---------------------------------------------------------------------------
344
345/// Render a PHPUnit test method for an HTTP server test fixture.
346fn render_http_test_method(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
347    let method_name = sanitize_filename(&fixture.id);
348    let description = &fixture.description;
349
350    let _ = writeln!(out, "    /** {description} */");
351    let _ = writeln!(out, "    public function test_{method_name}(): void");
352    let _ = writeln!(out, "    {{");
353
354    // Build request.
355    render_php_http_request(out, &http.request);
356
357    // Assert status code.
358    let status = http.expected_response.status_code;
359    let _ = writeln!(
360        out,
361        "        $this->assertEquals({status}, $response->getStatusCode());"
362    );
363
364    // Assert response body.
365    render_php_body_assertions(out, &http.expected_response);
366
367    // Assert response headers.
368    render_php_header_assertions(out, &http.expected_response);
369
370    let _ = writeln!(out, "    }}");
371}
372
373/// Emit Guzzle request lines inside a PHPUnit test method.
374fn render_php_http_request(out: &mut String, req: &HttpRequest) {
375    let method = req.method.to_uppercase();
376
377    // Build options array.
378    let mut opts: Vec<String> = Vec::new();
379
380    if let Some(body) = &req.body {
381        let php_body = json_to_php(body);
382        opts.push(format!("'json' => {php_body}"));
383    }
384
385    if !req.headers.is_empty() {
386        let header_pairs: Vec<String> = req
387            .headers
388            .iter()
389            .map(|(k, v)| format!("\"{}\" => \"{}\"", escape_php(k), escape_php(v)))
390            .collect();
391        opts.push(format!("'headers' => [{}]", header_pairs.join(", ")));
392    }
393
394    if !req.cookies.is_empty() {
395        let cookie_str = req
396            .cookies
397            .iter()
398            .map(|(k, v)| format!("{}={}", k, v))
399            .collect::<Vec<_>>()
400            .join("; ");
401        opts.push(format!("'headers' => ['Cookie' => \"{}\"]", escape_php(&cookie_str)));
402    }
403
404    if !req.query_params.is_empty() {
405        let pairs: Vec<String> = req
406            .query_params
407            .iter()
408            .map(|(k, v)| {
409                let val_str = match v {
410                    serde_json::Value::String(s) => s.clone(),
411                    other => other.to_string(),
412                };
413                format!("\"{}\" => \"{}\"", escape_php(k), escape_php(&val_str))
414            })
415            .collect();
416        opts.push(format!("'query' => [{}]", pairs.join(", ")));
417    }
418
419    let path_lit = format!("\"{}\"", escape_php(&req.path));
420    if opts.is_empty() {
421        let _ = writeln!(
422            out,
423            "        $response = $this->httpClient->request('{method}', {path_lit});"
424        );
425    } else {
426        let _ = writeln!(
427            out,
428            "        $response = $this->httpClient->request('{method}', {path_lit}, ["
429        );
430        for opt in &opts {
431            let _ = writeln!(out, "            {opt},");
432        }
433        let _ = writeln!(out, "        ]);");
434    }
435
436    // Decode JSON body for assertions.
437    let _ = writeln!(
438        out,
439        "        $body = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);"
440    );
441}
442
443/// Emit body assertions for an HTTP expected response.
444fn render_php_body_assertions(out: &mut String, expected: &HttpExpectedResponse) {
445    if let Some(body) = &expected.body {
446        let php_val = json_to_php(body);
447        let _ = writeln!(out, "        $this->assertEquals({php_val}, $body);");
448    }
449    if let Some(partial) = &expected.body_partial {
450        if let Some(obj) = partial.as_object() {
451            for (key, val) in obj {
452                let php_key = format!("\"{}\"", escape_php(key));
453                let php_val = json_to_php(val);
454                let _ = writeln!(out, "        $this->assertEquals({php_val}, $body[{php_key}]);");
455            }
456        }
457    }
458    if let Some(errors) = &expected.validation_errors {
459        for err in errors {
460            let msg_lit = format!("\"{}\"", escape_php(&err.msg));
461            let _ = writeln!(
462                out,
463                "        $this->assertStringContainsString({msg_lit}, json_encode($body));"
464            );
465        }
466    }
467}
468
469/// Emit header assertions for an HTTP expected response.
470///
471/// Special tokens:
472/// - `"<<present>>"` — assert the header exists
473/// - `"<<absent>>"` — assert the header is absent
474/// - `"<<uuid>>"` — assert the header matches a UUID regex
475fn render_php_header_assertions(out: &mut String, expected: &HttpExpectedResponse) {
476    for (name, value) in &expected.headers {
477        let header_key = name.to_lowercase();
478        let header_key_lit = format!("\"{}\"", escape_php(&header_key));
479        match value.as_str() {
480            "<<present>>" => {
481                let _ = writeln!(
482                    out,
483                    "        $this->assertTrue($response->hasHeader({header_key_lit}));"
484                );
485            }
486            "<<absent>>" => {
487                let _ = writeln!(
488                    out,
489                    "        $this->assertFalse($response->hasHeader({header_key_lit}));"
490                );
491            }
492            "<<uuid>>" => {
493                let _ = writeln!(
494                    out,
495                    "        $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}));"
496                );
497            }
498            literal => {
499                let val_lit = format!("\"{}\"", escape_php(literal));
500                let _ = writeln!(
501                    out,
502                    "        $this->assertEquals({val_lit}, $response->getHeaderLine({header_key_lit}));"
503                );
504            }
505        }
506    }
507}
508
509// ---------------------------------------------------------------------------
510// Function-call test rendering
511// ---------------------------------------------------------------------------
512
513#[allow(clippy::too_many_arguments)]
514fn render_test_method(
515    out: &mut String,
516    fixture: &Fixture,
517    e2e_config: &E2eConfig,
518    lang: &str,
519    namespace: &str,
520    class_name: &str,
521    field_resolver: &FieldResolver,
522    enum_fields: &HashMap<String, String>,
523    result_is_simple: bool,
524    php_client_factory: Option<&str>,
525    options_via: &str,
526) {
527    // Resolve per-fixture call config: supports named calls via fixture.call field.
528    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
529    let call_overrides = call_config.overrides.get(lang);
530    let mut function_name = call_overrides
531        .and_then(|o| o.function.as_ref())
532        .cloned()
533        .unwrap_or_else(|| call_config.function.clone());
534    // PHP ext-php-rs async methods have an _async suffix.
535    if call_config.r#async {
536        function_name = format!("{function_name}_async");
537    }
538    let result_var = &call_config.result_var;
539    let args = &call_config.args;
540
541    let method_name = sanitize_filename(&fixture.id);
542    let description = &fixture.description;
543    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
544
545    let (mut setup_lines, args_str) =
546        build_args_and_setup(&fixture.input, args, class_name, enum_fields, &fixture.id, options_via);
547
548    // Build visitor if present and add to setup
549    let mut visitor_arg = String::new();
550    if let Some(visitor_spec) = &fixture.visitor {
551        visitor_arg = build_php_visitor(&mut setup_lines, visitor_spec);
552    }
553
554    let final_args = if visitor_arg.is_empty() {
555        args_str
556    } else if args_str.is_empty() {
557        visitor_arg
558    } else {
559        format!("{args_str}, {visitor_arg}")
560    };
561
562    let call_expr = if php_client_factory.is_some() {
563        format!("$client->{function_name}({final_args})")
564    } else {
565        format!("{class_name}::{function_name}({final_args})")
566    };
567
568    let _ = writeln!(out, "    /** {description} */");
569    let _ = writeln!(out, "    public function test_{method_name}(): void");
570    let _ = writeln!(out, "    {{");
571
572    if let Some(factory) = php_client_factory {
573        let _ = writeln!(
574            out,
575            "        $client = \\{namespace}\\{class_name}::{factory}('test-key');"
576        );
577    }
578
579    for line in &setup_lines {
580        let _ = writeln!(out, "        {line}");
581    }
582
583    if expects_error {
584        let _ = writeln!(out, "        $this->expectException(\\Exception::class);");
585        let _ = writeln!(out, "        {call_expr};");
586        let _ = writeln!(out, "    }}");
587        return;
588    }
589
590    // If no assertion will actually produce a PHPUnit assert call, mark the test
591    // as intentionally assertion-free so PHPUnit does not flag it as risky.
592    let has_usable = fixture.assertions.iter().any(|a| {
593        if a.assertion_type == "error" || a.assertion_type == "not_error" {
594            return false;
595        }
596        match &a.field {
597            Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
598            _ => true,
599        }
600    });
601    if !has_usable {
602        let _ = writeln!(out, "        $this->expectNotToPerformAssertions();");
603    }
604
605    let _ = writeln!(out, "        ${result_var} = {call_expr};");
606
607    for assertion in &fixture.assertions {
608        render_assertion(out, assertion, result_var, field_resolver, result_is_simple);
609    }
610
611    let _ = writeln!(out, "    }}");
612}
613
614/// Build setup lines (e.g. handle creation) and the argument list for the function call.
615///
616/// `options_via` controls how `json_object` args are passed:
617/// - `"array"` (default): PHP array literal `["key" => value, ...]`
618/// - `"json"`: JSON string via `json_encode([...])` — use when the Rust method accepts `Option<String>`
619///
620/// Returns `(setup_lines, args_string)`.
621fn build_args_and_setup(
622    input: &serde_json::Value,
623    args: &[crate::config::ArgMapping],
624    class_name: &str,
625    enum_fields: &HashMap<String, String>,
626    fixture_id: &str,
627    options_via: &str,
628) -> (Vec<String>, String) {
629    if args.is_empty() {
630        // No args configuration: pass the whole input only if it's non-empty.
631        // Functions with no parameters (e.g. list_models) have empty input and get no args.
632        let is_empty_input = match input {
633            serde_json::Value::Null => true,
634            serde_json::Value::Object(m) => m.is_empty(),
635            _ => false,
636        };
637        if is_empty_input {
638            return (Vec::new(), String::new());
639        }
640        return (Vec::new(), json_to_php(input));
641    }
642
643    let mut setup_lines: Vec<String> = Vec::new();
644    let mut parts: Vec<String> = Vec::new();
645
646    for arg in args {
647        if arg.arg_type == "mock_url" {
648            setup_lines.push(format!(
649                "${} = getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}';",
650                arg.name,
651            ));
652            parts.push(format!("${}", arg.name));
653            continue;
654        }
655
656        if arg.arg_type == "handle" {
657            // Generate a createEngine (or equivalent) call and pass the variable.
658            let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
659            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
660            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
661            if config_value.is_null()
662                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
663            {
664                setup_lines.push(format!("${} = {class_name}::{constructor_name}(null);", arg.name,));
665            } else {
666                let name = &arg.name;
667                // Build a CrawlConfig object and set its fields via property assignment.
668                // The PHP binding accepts `?CrawlConfig $config` — there is no JSON string
669                // variant. Object and array config values are expressed as PHP array literals.
670                setup_lines.push(format!("${name}_config = CrawlConfig::default();"));
671                if let Some(obj) = config_value.as_object() {
672                    for (key, val) in obj {
673                        let php_val = json_to_php(val);
674                        setup_lines.push(format!("${name}_config->{key} = {php_val};"));
675                    }
676                }
677                setup_lines.push(format!(
678                    "${} = {class_name}::{constructor_name}(${name}_config);",
679                    arg.name,
680                ));
681            }
682            parts.push(format!("${}", arg.name));
683            continue;
684        }
685
686        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
687        let val = input.get(field);
688        match val {
689            None | Some(serde_json::Value::Null) if arg.optional => {
690                // Optional arg with no fixture value: skip entirely.
691                continue;
692            }
693            None | Some(serde_json::Value::Null) => {
694                // Required arg with no fixture value: pass a language-appropriate default.
695                let default_val = match arg.arg_type.as_str() {
696                    "string" => "\"\"".to_string(),
697                    "int" | "integer" => "0".to_string(),
698                    "float" | "number" => "0.0".to_string(),
699                    "bool" | "boolean" => "false".to_string(),
700                    "json_object" if options_via == "json" => "null".to_string(),
701                    _ => "null".to_string(),
702                };
703                parts.push(default_val);
704            }
705            Some(v) => {
706                if arg.arg_type == "json_object" && !v.is_null() {
707                    match options_via {
708                        "json" => {
709                            // Pass as JSON string via json_encode(); the Rust method accepts Option<String>.
710                            parts.push(format!("json_encode({})", json_to_php(v)));
711                            continue;
712                        }
713                        _ => {
714                            // Default: PHP array literal with snake_case keys.
715                            if let Some(obj) = v.as_object() {
716                                let items: Vec<String> = obj
717                                    .iter()
718                                    .map(|(k, vv)| {
719                                        let snake_key = k.to_snake_case();
720                                        let php_val = if enum_fields.contains_key(k) {
721                                            if let Some(s) = vv.as_str() {
722                                                let snake_val = s.to_snake_case();
723                                                format!("\"{}\"", escape_php(&snake_val))
724                                            } else {
725                                                json_to_php(vv)
726                                            }
727                                        } else {
728                                            json_to_php(vv)
729                                        };
730                                        format!("\"{}\" => {}", escape_php(&snake_key), php_val)
731                                    })
732                                    .collect();
733                                parts.push(format!("[{}]", items.join(", ")));
734                                continue;
735                            }
736                        }
737                    }
738                }
739                parts.push(json_to_php(v));
740            }
741        }
742    }
743
744    (setup_lines, parts.join(", "))
745}
746
747fn render_assertion(
748    out: &mut String,
749    assertion: &Assertion,
750    result_var: &str,
751    field_resolver: &FieldResolver,
752    result_is_simple: bool,
753) {
754    // Skip assertions on fields that don't exist on the result type.
755    if let Some(f) = &assertion.field {
756        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
757            let _ = writeln!(out, "        // skipped: field '{f}' not available on result type");
758            return;
759        }
760    }
761
762    // When result_is_simple, skip assertions that reference non-content fields
763    // (e.g., metadata, document, structure) since the binding returns a plain value.
764    if result_is_simple {
765        if let Some(f) = &assertion.field {
766            let f_lower = f.to_lowercase();
767            if !f.is_empty()
768                && f_lower != "content"
769                && (f_lower.starts_with("metadata")
770                    || f_lower.starts_with("document")
771                    || f_lower.starts_with("structure"))
772            {
773                let _ = writeln!(
774                    out,
775                    "        // skipped: result_is_simple, field '{f}' not on simple result type"
776                );
777                return;
778            }
779        }
780    }
781
782    let field_expr = if result_is_simple {
783        format!("${result_var}")
784    } else {
785        match &assertion.field {
786            Some(f) if !f.is_empty() => field_resolver.accessor(f, "php", &format!("${result_var}")),
787            _ => format!("${result_var}"),
788        }
789    };
790
791    // For string equality, trim trailing whitespace to handle trailing newlines.
792    let trimmed_field_expr = if result_is_simple {
793        format!("trim(${result_var})")
794    } else {
795        field_expr.clone()
796    };
797
798    match assertion.assertion_type.as_str() {
799        "equals" => {
800            if let Some(expected) = &assertion.value {
801                let php_val = json_to_php(expected);
802                let _ = writeln!(out, "        $this->assertEquals({php_val}, {trimmed_field_expr});");
803            }
804        }
805        "contains" => {
806            if let Some(expected) = &assertion.value {
807                let php_val = json_to_php(expected);
808                let _ = writeln!(
809                    out,
810                    "        $this->assertStringContainsString({php_val}, {field_expr});"
811                );
812            }
813        }
814        "contains_all" => {
815            if let Some(values) = &assertion.values {
816                for val in values {
817                    let php_val = json_to_php(val);
818                    let _ = writeln!(
819                        out,
820                        "        $this->assertStringContainsString({php_val}, {field_expr});"
821                    );
822                }
823            }
824        }
825        "not_contains" => {
826            if let Some(expected) = &assertion.value {
827                let php_val = json_to_php(expected);
828                let _ = writeln!(
829                    out,
830                    "        $this->assertStringNotContainsString({php_val}, {field_expr});"
831                );
832            }
833        }
834        "not_empty" => {
835            let _ = writeln!(out, "        $this->assertNotEmpty({field_expr});");
836        }
837        "is_empty" => {
838            let _ = writeln!(out, "        $this->assertEmpty({trimmed_field_expr});");
839        }
840        "contains_any" => {
841            if let Some(values) = &assertion.values {
842                let _ = writeln!(out, "        $found = false;");
843                for val in values {
844                    let php_val = json_to_php(val);
845                    let _ = writeln!(
846                        out,
847                        "        if (str_contains({field_expr}, {php_val})) {{ $found = true; }}"
848                    );
849                }
850                let _ = writeln!(
851                    out,
852                    "        $this->assertTrue($found, 'expected to contain at least one of the specified values');"
853                );
854            }
855        }
856        "greater_than" => {
857            if let Some(val) = &assertion.value {
858                let php_val = json_to_php(val);
859                let _ = writeln!(out, "        $this->assertGreaterThan({php_val}, {field_expr});");
860            }
861        }
862        "less_than" => {
863            if let Some(val) = &assertion.value {
864                let php_val = json_to_php(val);
865                let _ = writeln!(out, "        $this->assertLessThan({php_val}, {field_expr});");
866            }
867        }
868        "greater_than_or_equal" => {
869            if let Some(val) = &assertion.value {
870                let php_val = json_to_php(val);
871                let _ = writeln!(out, "        $this->assertGreaterThanOrEqual({php_val}, {field_expr});");
872            }
873        }
874        "less_than_or_equal" => {
875            if let Some(val) = &assertion.value {
876                let php_val = json_to_php(val);
877                let _ = writeln!(out, "        $this->assertLessThanOrEqual({php_val}, {field_expr});");
878            }
879        }
880        "starts_with" => {
881            if let Some(expected) = &assertion.value {
882                let php_val = json_to_php(expected);
883                let _ = writeln!(out, "        $this->assertStringStartsWith({php_val}, {field_expr});");
884            }
885        }
886        "ends_with" => {
887            if let Some(expected) = &assertion.value {
888                let php_val = json_to_php(expected);
889                let _ = writeln!(out, "        $this->assertStringEndsWith({php_val}, {field_expr});");
890            }
891        }
892        "min_length" => {
893            if let Some(val) = &assertion.value {
894                if let Some(n) = val.as_u64() {
895                    let _ = writeln!(
896                        out,
897                        "        $this->assertGreaterThanOrEqual({n}, strlen({field_expr}));"
898                    );
899                }
900            }
901        }
902        "max_length" => {
903            if let Some(val) = &assertion.value {
904                if let Some(n) = val.as_u64() {
905                    let _ = writeln!(out, "        $this->assertLessThanOrEqual({n}, strlen({field_expr}));");
906                }
907            }
908        }
909        "count_min" => {
910            if let Some(val) = &assertion.value {
911                if let Some(n) = val.as_u64() {
912                    let _ = writeln!(
913                        out,
914                        "        $this->assertGreaterThanOrEqual({n}, count({field_expr}));"
915                    );
916                }
917            }
918        }
919        "count_equals" => {
920            if let Some(val) = &assertion.value {
921                if let Some(n) = val.as_u64() {
922                    let _ = writeln!(out, "        $this->assertCount({n}, {field_expr});");
923                }
924            }
925        }
926        "is_true" => {
927            let _ = writeln!(out, "        $this->assertTrue({field_expr});");
928        }
929        "is_false" => {
930            let _ = writeln!(out, "        $this->assertFalse({field_expr});");
931        }
932        "method_result" => {
933            if let Some(method_name) = &assertion.method {
934                let call_expr = build_php_method_call(result_var, method_name, assertion.args.as_ref());
935                let check = assertion.check.as_deref().unwrap_or("is_true");
936                match check {
937                    "equals" => {
938                        if let Some(val) = &assertion.value {
939                            if val.is_boolean() {
940                                if val.as_bool() == Some(true) {
941                                    let _ = writeln!(out, "        $this->assertTrue({call_expr});");
942                                } else {
943                                    let _ = writeln!(out, "        $this->assertFalse({call_expr});");
944                                }
945                            } else {
946                                let expected = json_to_php(val);
947                                let _ = writeln!(out, "        $this->assertEquals({expected}, {call_expr});");
948                            }
949                        }
950                    }
951                    "is_true" => {
952                        let _ = writeln!(out, "        $this->assertTrue({call_expr});");
953                    }
954                    "is_false" => {
955                        let _ = writeln!(out, "        $this->assertFalse({call_expr});");
956                    }
957                    "greater_than_or_equal" => {
958                        if let Some(val) = &assertion.value {
959                            let n = val.as_u64().unwrap_or(0);
960                            let _ = writeln!(out, "        $this->assertGreaterThanOrEqual({n}, {call_expr});");
961                        }
962                    }
963                    "count_min" => {
964                        if let Some(val) = &assertion.value {
965                            let n = val.as_u64().unwrap_or(0);
966                            let _ = writeln!(out, "        $this->assertGreaterThanOrEqual({n}, count({call_expr}));");
967                        }
968                    }
969                    "is_error" => {
970                        let _ = writeln!(out, "        $this->expectException(\\Exception::class);");
971                        let _ = writeln!(out, "        {call_expr};");
972                    }
973                    "contains" => {
974                        if let Some(val) = &assertion.value {
975                            let expected = json_to_php(val);
976                            let _ = writeln!(
977                                out,
978                                "        $this->assertStringContainsString({expected}, {call_expr});"
979                            );
980                        }
981                    }
982                    other_check => {
983                        panic!("PHP e2e generator: unsupported method_result check type: {other_check}");
984                    }
985                }
986            } else {
987                panic!("PHP e2e generator: method_result assertion missing 'method' field");
988            }
989        }
990        "matches_regex" => {
991            if let Some(expected) = &assertion.value {
992                let php_val = json_to_php(expected);
993                let _ = writeln!(
994                    out,
995                    "        $this->assertMatchesRegularExpression({php_val}, {field_expr});"
996                );
997            }
998        }
999        "not_error" => {
1000            // Already handled by the call succeeding without exception.
1001        }
1002        "error" => {
1003            // Handled at the test method level.
1004        }
1005        other => {
1006            panic!("PHP e2e generator: unsupported assertion type: {other}");
1007        }
1008    }
1009}
1010
1011/// Build a PHP call expression for a `method_result` assertion on a tree-sitter `Tree`.
1012///
1013/// Maps method names to the appropriate PHP static function calls on the
1014/// `TreeSitterLanguagePack` class (using the ext-php-rs snake_case method names).
1015fn build_php_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
1016    match method_name {
1017        "root_child_count" => {
1018            format!("count(TreeSitterLanguagePack::named_children_info(${result_var}))")
1019        }
1020        "root_node_type" => {
1021            format!("TreeSitterLanguagePack::root_node_info(${result_var})->kind")
1022        }
1023        "named_children_count" => {
1024            format!("count(TreeSitterLanguagePack::named_children_info(${result_var}))")
1025        }
1026        "has_error_nodes" => {
1027            format!("TreeSitterLanguagePack::tree_has_error_nodes(${result_var})")
1028        }
1029        "error_count" | "tree_error_count" => {
1030            format!("TreeSitterLanguagePack::tree_error_count(${result_var})")
1031        }
1032        "tree_to_sexp" => {
1033            format!("TreeSitterLanguagePack::tree_to_sexp(${result_var})")
1034        }
1035        "contains_node_type" => {
1036            let node_type = args
1037                .and_then(|a| a.get("node_type"))
1038                .and_then(|v| v.as_str())
1039                .unwrap_or("");
1040            format!("TreeSitterLanguagePack::tree_contains_node_type(${result_var}, \"{node_type}\")")
1041        }
1042        "find_nodes_by_type" => {
1043            let node_type = args
1044                .and_then(|a| a.get("node_type"))
1045                .and_then(|v| v.as_str())
1046                .unwrap_or("");
1047            format!("TreeSitterLanguagePack::find_nodes_by_type(${result_var}, \"{node_type}\")")
1048        }
1049        "run_query" => {
1050            let query_source = args
1051                .and_then(|a| a.get("query_source"))
1052                .and_then(|v| v.as_str())
1053                .unwrap_or("");
1054            let language = args
1055                .and_then(|a| a.get("language"))
1056                .and_then(|v| v.as_str())
1057                .unwrap_or("");
1058            format!("TreeSitterLanguagePack::run_query(${result_var}, \"{language}\", \"{query_source}\", $source)")
1059        }
1060        _ => {
1061            format!("${result_var}->{method_name}()")
1062        }
1063    }
1064}
1065
1066/// Convert a `serde_json::Value` to a PHP literal string.
1067fn json_to_php(value: &serde_json::Value) -> String {
1068    match value {
1069        serde_json::Value::String(s) => format!("\"{}\"", escape_php(s)),
1070        serde_json::Value::Bool(true) => "true".to_string(),
1071        serde_json::Value::Bool(false) => "false".to_string(),
1072        serde_json::Value::Number(n) => n.to_string(),
1073        serde_json::Value::Null => "null".to_string(),
1074        serde_json::Value::Array(arr) => {
1075            let items: Vec<String> = arr.iter().map(json_to_php).collect();
1076            format!("[{}]", items.join(", "))
1077        }
1078        serde_json::Value::Object(map) => {
1079            let items: Vec<String> = map
1080                .iter()
1081                .map(|(k, v)| format!("\"{}\" => {}", escape_php(k), json_to_php(v)))
1082                .collect();
1083            format!("[{}]", items.join(", "))
1084        }
1085    }
1086}
1087
1088// ---------------------------------------------------------------------------
1089// Visitor generation
1090// ---------------------------------------------------------------------------
1091
1092/// Build a PHP visitor object and add setup lines. Returns the visitor expression.
1093fn build_php_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1094    setup_lines.push("$visitor = new class {".to_string());
1095    for (method_name, action) in &visitor_spec.callbacks {
1096        emit_php_visitor_method(setup_lines, method_name, action);
1097    }
1098    setup_lines.push("};".to_string());
1099    "$visitor".to_string()
1100}
1101
1102/// Emit a PHP visitor method for a callback action.
1103fn emit_php_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
1104    let snake_method = method_name;
1105    let params = match method_name {
1106        "visit_link" => "$ctx, $href, $text, $title",
1107        "visit_image" => "$ctx, $src, $alt, $title",
1108        "visit_heading" => "$ctx, $level, $text, $id",
1109        "visit_code_block" => "$ctx, $lang, $code",
1110        "visit_code_inline"
1111        | "visit_strong"
1112        | "visit_emphasis"
1113        | "visit_strikethrough"
1114        | "visit_underline"
1115        | "visit_subscript"
1116        | "visit_superscript"
1117        | "visit_mark"
1118        | "visit_button"
1119        | "visit_summary"
1120        | "visit_figcaption"
1121        | "visit_definition_term"
1122        | "visit_definition_description" => "$ctx, $text",
1123        "visit_text" => "$ctx, $text",
1124        "visit_list_item" => "$ctx, $ordered, $marker, $text",
1125        "visit_blockquote" => "$ctx, $content, $depth",
1126        "visit_table_row" => "$ctx, $cells, $isHeader",
1127        "visit_custom_element" => "$ctx, $tagName, $html",
1128        "visit_form" => "$ctx, $actionUrl, $method",
1129        "visit_input" => "$ctx, $inputType, $name, $value",
1130        "visit_audio" | "visit_video" | "visit_iframe" => "$ctx, $src",
1131        "visit_details" => "$ctx, $isOpen",
1132        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "$ctx, $output",
1133        "visit_list_start" => "$ctx, $ordered",
1134        "visit_list_end" => "$ctx, $ordered, $output",
1135        _ => "$ctx",
1136    };
1137
1138    setup_lines.push(format!("    public function {snake_method}({params}) {{"));
1139    match action {
1140        CallbackAction::Skip => {
1141            setup_lines.push("        return 'skip';".to_string());
1142        }
1143        CallbackAction::Continue => {
1144            setup_lines.push("        return 'continue';".to_string());
1145        }
1146        CallbackAction::PreserveHtml => {
1147            setup_lines.push("        return 'preserve_html';".to_string());
1148        }
1149        CallbackAction::Custom { output } => {
1150            let escaped = escape_php(output);
1151            setup_lines.push(format!("        return ['custom' => {escaped}];"));
1152        }
1153        CallbackAction::CustomTemplate { template } => {
1154            let escaped = escape_php(template);
1155            setup_lines.push(format!("        return ['custom' => {escaped}];"));
1156        }
1157    }
1158    setup_lines.push("    }".to_string());
1159}