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