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