Skip to main content

alef_e2e/codegen/
php.rs

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