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        // PSR-4 autoload keys appear inside a JSON document, so each PHP
102        // namespace separator must be JSON-escaped (`\` → `\\`). The trailing
103        // pair represents the PHP-mandated trailing `\` (which itself escapes
104        // to `\\` in JSON).
105        let php_namespace_escaped = alef_config.php_autoload_namespace().replace('\\', "\\\\");
106        let e2e_autoload_ns = format!("{php_namespace_escaped}\\\\E2e\\\\");
107
108        // Generate composer.json.
109        files.push(GeneratedFile {
110            path: output_base.join("composer.json"),
111            content: render_composer_json(
112                &e2e_pkg_name,
113                &e2e_autoload_ns,
114                &pkg_name,
115                &pkg_path,
116                &pkg_version,
117                e2e_config.dep_mode,
118            ),
119            generated_header: false,
120        });
121
122        // Generate phpunit.xml.
123        files.push(GeneratedFile {
124            path: output_base.join("phpunit.xml"),
125            content: render_phpunit_xml(),
126            generated_header: false,
127        });
128
129        // Check if any fixture is an HTTP test (needs mock server bootstrap).
130        let has_http_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| f.is_http_test());
131
132        // Generate bootstrap.php that loads both autoloaders and optionally starts the mock server.
133        files.push(GeneratedFile {
134            path: output_base.join("bootstrap.php"),
135            content: render_bootstrap(&pkg_path, has_http_fixtures),
136            generated_header: true,
137        });
138
139        // Generate test files per category.
140        let tests_base = output_base.join("tests");
141        let field_resolver = FieldResolver::new(
142            &e2e_config.fields,
143            &e2e_config.fields_optional,
144            &e2e_config.result_fields,
145            &e2e_config.fields_array,
146        );
147
148        for group in groups {
149            let active: Vec<&Fixture> = group
150                .fixtures
151                .iter()
152                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
153                .collect();
154
155            if active.is_empty() {
156                continue;
157            }
158
159            let test_class = format!("{}Test", sanitize_filename(&group.category).to_upper_camel_case());
160            let filename = format!("{test_class}.php");
161            let content = render_test_file(
162                &group.category,
163                &active,
164                e2e_config,
165                lang,
166                &namespace,
167                &class_name,
168                &test_class,
169                &field_resolver,
170                enum_fields,
171                result_is_simple,
172                php_client_factory,
173                options_via,
174            );
175            files.push(GeneratedFile {
176                path: tests_base.join(filename),
177                content,
178                generated_header: true,
179            });
180        }
181
182        Ok(files)
183    }
184
185    fn language_name(&self) -> &'static str {
186        "php"
187    }
188}
189
190// ---------------------------------------------------------------------------
191// Rendering
192// ---------------------------------------------------------------------------
193
194fn render_composer_json(
195    e2e_pkg_name: &str,
196    e2e_autoload_ns: &str,
197    pkg_name: &str,
198    _pkg_path: &str,
199    pkg_version: &str,
200    dep_mode: crate::config::DependencyMode,
201) -> String {
202    let require_section = match dep_mode {
203        crate::config::DependencyMode::Registry => {
204            format!(
205                r#"  "require": {{
206    "{pkg_name}": "{pkg_version}"
207  }},
208  "require-dev": {{
209    "phpunit/phpunit": "{phpunit}",
210    "guzzlehttp/guzzle": "{guzzle}"
211  }},"#,
212                phpunit = tv::packagist::PHPUNIT,
213                guzzle = tv::packagist::GUZZLE,
214            )
215        }
216        crate::config::DependencyMode::Local => format!(
217            r#"  "require-dev": {{
218    "phpunit/phpunit": "{phpunit}",
219    "guzzlehttp/guzzle": "{guzzle}"
220  }},"#,
221            phpunit = tv::packagist::PHPUNIT,
222            guzzle = tv::packagist::GUZZLE,
223        ),
224    };
225
226    format!(
227        r#"{{
228  "name": "{e2e_pkg_name}",
229  "description": "E2e tests for PHP bindings",
230  "type": "project",
231{require_section}
232  "autoload-dev": {{
233    "psr-4": {{
234      "{e2e_autoload_ns}": "tests/"
235    }}
236  }}
237}}
238"#
239    )
240}
241
242fn render_phpunit_xml() -> String {
243    r#"<?xml version="1.0" encoding="UTF-8"?>
244<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
245         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/13.1/phpunit.xsd"
246         bootstrap="bootstrap.php"
247         colors="true"
248         failOnRisky="true"
249         failOnWarning="true">
250    <testsuites>
251        <testsuite name="e2e">
252            <directory>tests</directory>
253        </testsuite>
254    </testsuites>
255</phpunit>
256"#
257    .to_string()
258}
259
260fn render_bootstrap(pkg_path: &str, has_http_fixtures: bool) -> String {
261    let header = hash::header(CommentStyle::DoubleSlash);
262    let mock_server_block = if has_http_fixtures {
263        r#"
264// Spawn the mock HTTP server binary for HTTP fixture tests.
265$mockServerBin = __DIR__ . '/../rust/target/release/mock-server';
266$fixturesDir = __DIR__ . '/../../fixtures';
267if (file_exists($mockServerBin)) {
268    $descriptors = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => STDERR];
269    $proc = proc_open([$mockServerBin, $fixturesDir], $descriptors, $pipes);
270    if (is_resource($proc)) {
271        $line = fgets($pipes[1]);
272        if ($line !== false && str_starts_with($line, 'MOCK_SERVER_URL=')) {
273            putenv(trim($line));
274            $_ENV['MOCK_SERVER_URL'] = trim(substr(trim($line), strlen('MOCK_SERVER_URL=')));
275        }
276        // Drain stdout in background thread is not possible in PHP; keep pipe open.
277        register_shutdown_function(static function () use ($proc, $pipes): void {
278            fclose($pipes[0]);
279            proc_close($proc);
280        });
281    }
282}
283"#
284    } else {
285        ""
286    };
287    format!(
288        r#"<?php
289{header}
290declare(strict_types=1);
291
292// Load the e2e project autoloader (PHPUnit, test helpers).
293require_once __DIR__ . '/vendor/autoload.php';
294
295// Load the PHP binding package classes via its Composer autoloader.
296// The package's autoloader is separate from the e2e project's autoloader
297// since the php-ext type prevents direct composer path dependency.
298$pkgAutoloader = __DIR__ . '/{pkg_path}/vendor/autoload.php';
299if (file_exists($pkgAutoloader)) {{
300    require_once $pkgAutoloader;
301}}{mock_server_block}
302"#
303    )
304}
305
306#[allow(clippy::too_many_arguments)]
307fn render_test_file(
308    category: &str,
309    fixtures: &[&Fixture],
310    e2e_config: &E2eConfig,
311    lang: &str,
312    namespace: &str,
313    class_name: &str,
314    test_class: &str,
315    field_resolver: &FieldResolver,
316    enum_fields: &HashMap<String, String>,
317    result_is_simple: bool,
318    php_client_factory: Option<&str>,
319    options_via: &str,
320) -> String {
321    let mut out = String::new();
322    let _ = writeln!(out, "<?php");
323    out.push_str(&hash::header(CommentStyle::DoubleSlash));
324    let _ = writeln!(out);
325    let _ = writeln!(out, "declare(strict_types=1);");
326    let _ = writeln!(out);
327    let _ = writeln!(out, "namespace Kreuzberg\\E2e;");
328    let _ = writeln!(out);
329
330    // Determine if any handle arg has a non-null config (needs CrawlConfig import).
331    let needs_crawl_config_import = fixtures.iter().any(|f| {
332        let call = e2e_config.resolve_call(f.call.as_deref());
333        call.args.iter().filter(|a| a.arg_type == "handle").any(|a| {
334            let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
335            !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
336        })
337    });
338
339    // Determine if any fixture is an HTTP test (needs GuzzleHttp).
340    let has_http_tests = fixtures.iter().any(|f| f.is_http_test());
341
342    let _ = writeln!(out, "use PHPUnit\\Framework\\TestCase;");
343    let _ = writeln!(out, "use {namespace}\\{class_name};");
344    if needs_crawl_config_import {
345        let _ = writeln!(out, "use {namespace}\\CrawlConfig;");
346    }
347    if has_http_tests {
348        let _ = writeln!(out, "use GuzzleHttp\\Client;");
349    }
350    let _ = writeln!(out);
351    let _ = writeln!(out, "/** E2e tests for category: {category}. */");
352    let _ = writeln!(out, "final class {test_class} extends TestCase");
353    let _ = writeln!(out, "{{");
354
355    // Emit a shared HTTP client property when there are HTTP tests.
356    if has_http_tests {
357        let _ = writeln!(out, "    private Client $httpClient;");
358        let _ = writeln!(out);
359        let _ = writeln!(out, "    protected function setUp(): void");
360        let _ = writeln!(out, "    {{");
361        let _ = writeln!(out, "        parent::setUp();");
362        let _ = writeln!(
363            out,
364            "        $baseUrl = (string)(getenv('MOCK_SERVER_URL') ?: 'http://localhost:8080');"
365        );
366        let _ = writeln!(
367            out,
368            "        $this->httpClient = new Client(['base_uri' => $baseUrl, 'http_errors' => false, 'decode_content' => false, 'allow_redirects' => false]);"
369        );
370        let _ = writeln!(out, "    }}");
371        let _ = writeln!(out);
372    }
373
374    for (i, fixture) in fixtures.iter().enumerate() {
375        if fixture.is_http_test() {
376            render_http_test_method(&mut out, fixture, fixture.http.as_ref().unwrap());
377        } else {
378            render_test_method(
379                &mut out,
380                fixture,
381                e2e_config,
382                lang,
383                namespace,
384                class_name,
385                field_resolver,
386                enum_fields,
387                result_is_simple,
388                php_client_factory,
389                options_via,
390            );
391        }
392        if i + 1 < fixtures.len() {
393            let _ = writeln!(out);
394        }
395    }
396
397    let _ = writeln!(out, "}}");
398    out
399}
400
401// ---------------------------------------------------------------------------
402// HTTP test rendering
403// ---------------------------------------------------------------------------
404
405/// Render a PHPUnit test method for an HTTP server test fixture.
406fn render_http_test_method(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
407    let method_name = sanitize_filename(&fixture.id);
408    let description = &fixture.description;
409    let fixture_id = &fixture.id;
410
411    // HTTP 101 (WebSocket upgrade) causes cURL to treat the connection as an upgrade
412    // and fail with "empty reply from server". Skip these tests in the PHP e2e suite
413    // since Guzzle cannot assert on WebSocket upgrade responses via regular HTTP.
414    let status = http.expected_response.status_code;
415    if status == 101 {
416        let _ = writeln!(out, "    /** {description} */");
417        let _ = writeln!(out, "    public function test_{method_name}(): void");
418        let _ = writeln!(out, "    {{");
419        let _ = writeln!(
420            out,
421            "        $this->markTestSkipped('HTTP 101 WebSocket upgrade cannot be tested via Guzzle HTTP client');"
422        );
423        let _ = writeln!(out, "    }}");
424        return;
425    }
426
427    // Determine body assertion strategy:
428    // - String bodies: mock server returns raw text, compare via (string)$response->getBody()
429    // - Object/array bodies: use json_decode + assertEquals
430    // - Empty string sentinel ("") or null: no body assertion
431    let body_is_plain_string =
432        matches!(&http.expected_response.body, Some(serde_json::Value::String(s)) if !s.is_empty());
433    let has_explicit_body =
434        matches!(&http.expected_response.body, Some(v) if !(v.is_null() || v.is_string() && v.as_str() == Some("")));
435    // Only call json_decode for non-string bodies (objects, arrays, booleans, numbers).
436    let needs_json_body = has_explicit_body && !body_is_plain_string || http.expected_response.body_partial.is_some();
437
438    let _ = writeln!(out, "    /** {description} */");
439    let _ = writeln!(out, "    public function test_{method_name}(): void");
440    let _ = writeln!(out, "    {{");
441
442    // Build request targeting the mock server's /fixtures/<id> endpoint.
443    render_php_http_request(out, &http.request, fixture_id, needs_json_body);
444
445    // Assert status code.
446    let _ = writeln!(
447        out,
448        "        $this->assertEquals({status}, $response->getStatusCode());"
449    );
450
451    // For plain string bodies, compare the raw response body string directly.
452    if body_is_plain_string {
453        if let Some(serde_json::Value::String(expected_str)) = &http.expected_response.body {
454            let php_val = format!("\"{}\"", escape_php(expected_str));
455            let _ = writeln!(
456                out,
457                "        $this->assertEquals({php_val}, (string) $response->getBody());"
458            );
459        }
460        // Still assert headers if any.
461        render_php_header_assertions(out, &http.expected_response);
462        let _ = writeln!(out, "    }}");
463        return;
464    }
465
466    // Assert response body (JSON decode path).
467    render_php_body_assertions(out, &http.expected_response, needs_json_body);
468
469    // Assert response headers.
470    render_php_header_assertions(out, &http.expected_response);
471
472    let _ = writeln!(out, "    }}");
473}
474
475/// Emit Guzzle request lines inside a PHPUnit test method.
476/// `needs_json_body` controls whether a `$body = json_decode(...)` line is emitted.
477/// Skip it for responses with no body (204, 304, HEAD, etc.) to avoid JsonException.
478fn render_php_http_request(out: &mut String, req: &HttpRequest, fixture_id: &str, needs_json_body: bool) {
479    let method = req.method.to_uppercase();
480
481    // Build options array.
482    let mut opts: Vec<String> = Vec::new();
483
484    if let Some(body) = &req.body {
485        let php_body = json_to_php(body);
486        opts.push(format!("'json' => {php_body}"));
487    }
488
489    if !req.headers.is_empty() {
490        let header_pairs: Vec<String> = req
491            .headers
492            .iter()
493            .map(|(k, v)| format!("\"{}\" => \"{}\"", escape_php(k), escape_php(v)))
494            .collect();
495        opts.push(format!("'headers' => [{}]", header_pairs.join(", ")));
496    }
497
498    if !req.cookies.is_empty() {
499        let cookie_str = req
500            .cookies
501            .iter()
502            .map(|(k, v)| format!("{}={}", k, v))
503            .collect::<Vec<_>>()
504            .join("; ");
505        opts.push(format!("'headers' => ['Cookie' => \"{}\"]", escape_php(&cookie_str)));
506    }
507
508    if !req.query_params.is_empty() {
509        let pairs: Vec<String> = req
510            .query_params
511            .iter()
512            .map(|(k, v)| {
513                let val_str = match v {
514                    serde_json::Value::String(s) => s.clone(),
515                    other => other.to_string(),
516                };
517                format!("\"{}\" => \"{}\"", escape_php(k), escape_php(&val_str))
518            })
519            .collect();
520        opts.push(format!("'query' => [{}]", pairs.join(", ")));
521    }
522
523    // Use the mock server's /fixtures/<id> endpoint.
524    let path_lit = format!("\"/fixtures/{}\"", escape_php(fixture_id));
525    if opts.is_empty() {
526        let _ = writeln!(
527            out,
528            "        $response = $this->httpClient->request('{method}', {path_lit});"
529        );
530    } else {
531        let _ = writeln!(
532            out,
533            "        $response = $this->httpClient->request('{method}', {path_lit}, ["
534        );
535        for opt in &opts {
536            let _ = writeln!(out, "            {opt},");
537        }
538        let _ = writeln!(out, "        ]);");
539    }
540
541    // Decode JSON body for assertions only when body assertions are expected.
542    // Omitting json_decode for empty-body responses (204, 304, HEAD, etc.)
543    // prevents JsonException on non-JSON or empty response bodies.
544    if needs_json_body {
545        let _ = writeln!(
546            out,
547            "        $body = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);"
548        );
549    }
550}
551
552/// Emit body assertions for an HTTP expected response.
553/// `body_was_decoded` indicates whether `$body` is already in scope from a json_decode call.
554fn render_php_body_assertions(out: &mut String, expected: &HttpExpectedResponse, body_was_decoded: bool) {
555    if let Some(body) = &expected.body {
556        // Skip assertion when body is the empty-string sentinel (means no body expected).
557        if !(body.is_string() && body.as_str() == Some("")) {
558            let php_val = json_to_php(body);
559            let _ = writeln!(out, "        $this->assertEquals({php_val}, $body);");
560        }
561    }
562    if let Some(partial) = &expected.body_partial {
563        if let Some(obj) = partial.as_object() {
564            for (key, val) in obj {
565                let php_key = format!("\"{}\"", escape_php(key));
566                let php_val = json_to_php(val);
567                let _ = writeln!(out, "        $this->assertEquals({php_val}, $body[{php_key}]);");
568            }
569        }
570    }
571    if let Some(errors) = &expected.validation_errors {
572        // Skip validation_error string checks when a full body assertEquals is already
573        // generated — it is redundant and json_encode() escapes slashes differently
574        // across PHP versions, causing spurious failures.
575        if expected.body.is_none() {
576            // Ensure $body is available even when it wasn't json_decoded earlier.
577            if !body_was_decoded {
578                let _ = writeln!(out, "        $body = json_decode((string) $response->getBody(), true);");
579            }
580            for err in errors {
581                let msg_lit = format!("\"{}\"", escape_php(&err.msg));
582                let _ = writeln!(
583                    out,
584                    "        $this->assertStringContainsString({msg_lit}, json_encode($body, JSON_UNESCAPED_SLASHES));"
585                );
586            }
587        }
588    }
589}
590
591/// Emit header assertions for an HTTP expected response.
592///
593/// Special tokens:
594/// - `"<<present>>"` — assert the header exists
595/// - `"<<absent>>"` — assert the header is absent
596/// - `"<<uuid>>"` — assert the header matches a UUID regex
597fn render_php_header_assertions(out: &mut String, expected: &HttpExpectedResponse) {
598    for (name, value) in &expected.headers {
599        let header_key = name.to_lowercase();
600        // The mock server strips content-encoding headers because it serves uncompressed
601        // bodies. Skip asserting this header so tests don't fail against the mock server.
602        if header_key == "content-encoding" {
603            continue;
604        }
605        let header_key_lit = format!("\"{}\"", escape_php(&header_key));
606        match value.as_str() {
607            "<<present>>" => {
608                let _ = writeln!(
609                    out,
610                    "        $this->assertTrue($response->hasHeader({header_key_lit}));"
611                );
612            }
613            "<<absent>>" => {
614                let _ = writeln!(
615                    out,
616                    "        $this->assertFalse($response->hasHeader({header_key_lit}));"
617                );
618            }
619            "<<uuid>>" => {
620                let _ = writeln!(
621                    out,
622                    "        $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}));"
623                );
624            }
625            literal => {
626                let val_lit = format!("\"{}\"", escape_php(literal));
627                let _ = writeln!(
628                    out,
629                    "        $this->assertEquals({val_lit}, $response->getHeaderLine({header_key_lit}));"
630                );
631            }
632        }
633    }
634}
635
636// ---------------------------------------------------------------------------
637// Function-call test rendering
638// ---------------------------------------------------------------------------
639
640#[allow(clippy::too_many_arguments)]
641fn render_test_method(
642    out: &mut String,
643    fixture: &Fixture,
644    e2e_config: &E2eConfig,
645    lang: &str,
646    namespace: &str,
647    class_name: &str,
648    field_resolver: &FieldResolver,
649    enum_fields: &HashMap<String, String>,
650    result_is_simple: bool,
651    php_client_factory: Option<&str>,
652    options_via: &str,
653) {
654    // Resolve per-fixture call config: supports named calls via fixture.call field.
655    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
656    let call_overrides = call_config.overrides.get(lang);
657    let mut function_name = call_overrides
658        .and_then(|o| o.function.as_ref())
659        .cloned()
660        .unwrap_or_else(|| call_config.function.clone());
661    // PHP ext-php-rs async methods have an _async suffix.
662    if call_config.r#async {
663        function_name = format!("{function_name}_async");
664    }
665    let result_var = &call_config.result_var;
666    let args = &call_config.args;
667
668    let method_name = sanitize_filename(&fixture.id);
669    let description = &fixture.description;
670    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
671
672    let (mut setup_lines, args_str) =
673        build_args_and_setup(&fixture.input, args, class_name, enum_fields, &fixture.id, options_via);
674
675    // Build visitor if present and add to setup
676    let mut visitor_arg = String::new();
677    if let Some(visitor_spec) = &fixture.visitor {
678        visitor_arg = build_php_visitor(&mut setup_lines, visitor_spec);
679    }
680
681    let final_args = if visitor_arg.is_empty() {
682        args_str
683    } else if args_str.is_empty() {
684        visitor_arg
685    } else {
686        format!("{args_str}, {visitor_arg}")
687    };
688
689    let call_expr = if php_client_factory.is_some() {
690        format!("$client->{function_name}({final_args})")
691    } else {
692        format!("{class_name}::{function_name}({final_args})")
693    };
694
695    let _ = writeln!(out, "    /** {description} */");
696    let _ = writeln!(out, "    public function test_{method_name}(): void");
697    let _ = writeln!(out, "    {{");
698
699    if let Some(factory) = php_client_factory {
700        let _ = writeln!(
701            out,
702            "        $client = \\{namespace}\\{class_name}::{factory}('test-key');"
703        );
704    }
705
706    for line in &setup_lines {
707        let _ = writeln!(out, "        {line}");
708    }
709
710    if expects_error {
711        let _ = writeln!(out, "        $this->expectException(\\Exception::class);");
712        let _ = writeln!(out, "        {call_expr};");
713        let _ = writeln!(out, "    }}");
714        return;
715    }
716
717    // Non-HTTP fixture with no assertions: generate a skipped placeholder so
718    // PHPUnit does not try to call a method that may not exist on the binding.
719    if fixture.assertions.is_empty() {
720        let _ = writeln!(
721            out,
722            "        $this->markTestSkipped('no assertions configured for this fixture in php e2e');"
723        );
724        let _ = writeln!(out, "    }}");
725        return;
726    }
727
728    // If no assertion will actually produce a PHPUnit assert call, mark the test
729    // as intentionally assertion-free so PHPUnit does not flag it as risky.
730    let has_usable = fixture.assertions.iter().any(|a| {
731        if a.assertion_type == "error" || a.assertion_type == "not_error" {
732            return false;
733        }
734        match &a.field {
735            Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
736            _ => true,
737        }
738    });
739    if !has_usable {
740        let _ = writeln!(out, "        $this->expectNotToPerformAssertions();");
741    }
742
743    let _ = writeln!(out, "        ${result_var} = {call_expr};");
744
745    for assertion in &fixture.assertions {
746        render_assertion(out, assertion, result_var, field_resolver, result_is_simple);
747    }
748
749    let _ = writeln!(out, "    }}");
750}
751
752/// Build setup lines (e.g. handle creation) and the argument list for the function call.
753///
754/// `options_via` controls how `json_object` args are passed:
755/// - `"array"` (default): PHP array literal `["key" => value, ...]`
756/// - `"json"`: JSON string via `json_encode([...])` — use when the Rust method accepts `Option<String>`
757///
758/// Returns `(setup_lines, args_string)`.
759fn build_args_and_setup(
760    input: &serde_json::Value,
761    args: &[crate::config::ArgMapping],
762    class_name: &str,
763    enum_fields: &HashMap<String, String>,
764    fixture_id: &str,
765    options_via: &str,
766) -> (Vec<String>, String) {
767    if args.is_empty() {
768        // No args configuration: pass the whole input only if it's non-empty.
769        // Functions with no parameters (e.g. list_models) have empty input and get no args.
770        let is_empty_input = match input {
771            serde_json::Value::Null => true,
772            serde_json::Value::Object(m) => m.is_empty(),
773            _ => false,
774        };
775        if is_empty_input {
776            return (Vec::new(), String::new());
777        }
778        return (Vec::new(), json_to_php(input));
779    }
780
781    let mut setup_lines: Vec<String> = Vec::new();
782    let mut parts: Vec<String> = Vec::new();
783
784    for arg in args {
785        if arg.arg_type == "mock_url" {
786            setup_lines.push(format!(
787                "${} = getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}';",
788                arg.name,
789            ));
790            parts.push(format!("${}", arg.name));
791            continue;
792        }
793
794        if arg.arg_type == "handle" {
795            // Generate a createEngine (or equivalent) call and pass the variable.
796            let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
797            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
798            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
799            if config_value.is_null()
800                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
801            {
802                setup_lines.push(format!("${} = {class_name}::{constructor_name}(null);", arg.name,));
803            } else {
804                let name = &arg.name;
805                // Build a CrawlConfig object and set its fields via property assignment.
806                // The PHP binding accepts `?CrawlConfig $config` — there is no JSON string
807                // variant. Object and array config values are expressed as PHP array literals.
808                setup_lines.push(format!("${name}_config = CrawlConfig::default();"));
809                if let Some(obj) = config_value.as_object() {
810                    for (key, val) in obj {
811                        let php_val = json_to_php(val);
812                        setup_lines.push(format!("${name}_config->{key} = {php_val};"));
813                    }
814                }
815                setup_lines.push(format!(
816                    "${} = {class_name}::{constructor_name}(${name}_config);",
817                    arg.name,
818                ));
819            }
820            parts.push(format!("${}", arg.name));
821            continue;
822        }
823
824        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
825        let val = input.get(field);
826        match val {
827            None | Some(serde_json::Value::Null) if arg.optional => {
828                // Optional arg with no fixture value: skip entirely.
829                continue;
830            }
831            None | Some(serde_json::Value::Null) => {
832                // Required arg with no fixture value: pass a language-appropriate default.
833                let default_val = match arg.arg_type.as_str() {
834                    "string" => "\"\"".to_string(),
835                    "int" | "integer" => "0".to_string(),
836                    "float" | "number" => "0.0".to_string(),
837                    "bool" | "boolean" => "false".to_string(),
838                    "json_object" if options_via == "json" => "null".to_string(),
839                    _ => "null".to_string(),
840                };
841                parts.push(default_val);
842            }
843            Some(v) => {
844                if arg.arg_type == "json_object" && !v.is_null() {
845                    match options_via {
846                        "json" => {
847                            // Pass as JSON string via json_encode(); the Rust method accepts Option<String>.
848                            parts.push(format!("json_encode({})", json_to_php(v)));
849                            continue;
850                        }
851                        _ => {
852                            // Default: PHP array literal with snake_case keys.
853                            if let Some(obj) = v.as_object() {
854                                let items: Vec<String> = obj
855                                    .iter()
856                                    .map(|(k, vv)| {
857                                        let snake_key = k.to_snake_case();
858                                        let php_val = if enum_fields.contains_key(k) {
859                                            if let Some(s) = vv.as_str() {
860                                                let snake_val = s.to_snake_case();
861                                                format!("\"{}\"", escape_php(&snake_val))
862                                            } else {
863                                                json_to_php(vv)
864                                            }
865                                        } else {
866                                            json_to_php(vv)
867                                        };
868                                        format!("\"{}\" => {}", escape_php(&snake_key), php_val)
869                                    })
870                                    .collect();
871                                parts.push(format!("[{}]", items.join(", ")));
872                                continue;
873                            }
874                        }
875                    }
876                }
877                parts.push(json_to_php(v));
878            }
879        }
880    }
881
882    (setup_lines, parts.join(", "))
883}
884
885fn render_assertion(
886    out: &mut String,
887    assertion: &Assertion,
888    result_var: &str,
889    field_resolver: &FieldResolver,
890    result_is_simple: bool,
891) {
892    // Handle synthetic / derived fields before the is_valid_for_result check
893    // so they are never treated as struct property accesses on the result.
894    if let Some(f) = &assertion.field {
895        match f.as_str() {
896            "chunks_have_content" => {
897                let pred = format!(
898                    "array_reduce(${result_var}->chunks ?? [], fn($carry, $c) => $carry && !empty($c->content), true)"
899                );
900                match assertion.assertion_type.as_str() {
901                    "is_true" => {
902                        let _ = writeln!(out, "        $this->assertTrue({pred});");
903                    }
904                    "is_false" => {
905                        let _ = writeln!(out, "        $this->assertFalse({pred});");
906                    }
907                    _ => {
908                        let _ = writeln!(
909                            out,
910                            "        // skipped: unsupported assertion type on synthetic field '{f}'"
911                        );
912                    }
913                }
914                return;
915            }
916            "chunks_have_embeddings" => {
917                let pred = format!(
918                    "array_reduce(${result_var}->chunks ?? [], fn($carry, $c) => $carry && !empty($c->embedding), true)"
919                );
920                match assertion.assertion_type.as_str() {
921                    "is_true" => {
922                        let _ = writeln!(out, "        $this->assertTrue({pred});");
923                    }
924                    "is_false" => {
925                        let _ = writeln!(out, "        $this->assertFalse({pred});");
926                    }
927                    _ => {
928                        let _ = writeln!(
929                            out,
930                            "        // skipped: unsupported assertion type on synthetic field '{f}'"
931                        );
932                    }
933                }
934                return;
935            }
936            // ---- EmbedResponse virtual fields ----
937            // embed_texts returns array<array<float>> in PHP — no wrapper object.
938            // $result_var is the embedding matrix; use it directly.
939            "embeddings" => {
940                match assertion.assertion_type.as_str() {
941                    "count_equals" => {
942                        if let Some(val) = &assertion.value {
943                            let php_val = json_to_php(val);
944                            let _ = writeln!(out, "        $this->assertCount({php_val}, ${result_var});");
945                        }
946                    }
947                    "count_min" => {
948                        if let Some(val) = &assertion.value {
949                            let php_val = json_to_php(val);
950                            let _ = writeln!(
951                                out,
952                                "        $this->assertGreaterThanOrEqual({php_val}, count(${result_var}));"
953                            );
954                        }
955                    }
956                    "not_empty" => {
957                        let _ = writeln!(out, "        $this->assertNotEmpty(${result_var});");
958                    }
959                    "is_empty" => {
960                        let _ = writeln!(out, "        $this->assertEmpty(${result_var});");
961                    }
962                    _ => {
963                        let _ = writeln!(
964                            out,
965                            "        // skipped: unsupported assertion type on synthetic field 'embeddings'"
966                        );
967                    }
968                }
969                return;
970            }
971            "embedding_dimensions" => {
972                let expr = format!("(empty(${result_var}) ? 0 : count(${result_var}[0]))");
973                match assertion.assertion_type.as_str() {
974                    "equals" => {
975                        if let Some(val) = &assertion.value {
976                            let php_val = json_to_php(val);
977                            let _ = writeln!(out, "        $this->assertEquals({php_val}, {expr});");
978                        }
979                    }
980                    "greater_than" => {
981                        if let Some(val) = &assertion.value {
982                            let php_val = json_to_php(val);
983                            let _ = writeln!(out, "        $this->assertGreaterThan({php_val}, {expr});");
984                        }
985                    }
986                    _ => {
987                        let _ = writeln!(
988                            out,
989                            "        // skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
990                        );
991                    }
992                }
993                return;
994            }
995            "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
996                let pred = match f.as_str() {
997                    "embeddings_valid" => {
998                        format!("array_reduce(${result_var}, fn($carry, $e) => $carry && count($e) > 0, true)")
999                    }
1000                    "embeddings_finite" => {
1001                        format!(
1002                            "array_reduce(${result_var}, fn($carry, $e) => $carry && array_reduce($e, fn($c, $v) => $c && is_finite($v), true), true)"
1003                        )
1004                    }
1005                    "embeddings_non_zero" => {
1006                        format!(
1007                            "array_reduce(${result_var}, fn($carry, $e) => $carry && count(array_filter($e, fn($v) => $v !== 0.0)) > 0, true)"
1008                        )
1009                    }
1010                    "embeddings_normalized" => {
1011                        format!(
1012                            "array_reduce(${result_var}, fn($carry, $e) => $carry && abs(array_sum(array_map(fn($v) => $v * $v, $e)) - 1.0) < 1e-3, true)"
1013                        )
1014                    }
1015                    _ => unreachable!(),
1016                };
1017                match assertion.assertion_type.as_str() {
1018                    "is_true" => {
1019                        let _ = writeln!(out, "        $this->assertTrue({pred});");
1020                    }
1021                    "is_false" => {
1022                        let _ = writeln!(out, "        $this->assertFalse({pred});");
1023                    }
1024                    _ => {
1025                        let _ = writeln!(
1026                            out,
1027                            "        // skipped: unsupported assertion type on synthetic field '{f}'"
1028                        );
1029                    }
1030                }
1031                return;
1032            }
1033            // ---- keywords / keywords_count ----
1034            // PHP ExtractionResult does not expose extracted_keywords; skip.
1035            "keywords" | "keywords_count" => {
1036                let _ = writeln!(
1037                    out,
1038                    "        // skipped: field '{f}' not available on PHP ExtractionResult"
1039                );
1040                return;
1041            }
1042            _ => {}
1043        }
1044    }
1045
1046    // Skip assertions on fields that don't exist on the result type.
1047    if let Some(f) = &assertion.field {
1048        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1049            let _ = writeln!(out, "        // skipped: field '{f}' not available on result type");
1050            return;
1051        }
1052    }
1053
1054    // When result_is_simple, skip assertions that reference non-content fields
1055    // (e.g., metadata, document, structure) since the binding returns a plain value.
1056    if result_is_simple {
1057        if let Some(f) = &assertion.field {
1058            let f_lower = f.to_lowercase();
1059            if !f.is_empty()
1060                && f_lower != "content"
1061                && (f_lower.starts_with("metadata")
1062                    || f_lower.starts_with("document")
1063                    || f_lower.starts_with("structure"))
1064            {
1065                let _ = writeln!(
1066                    out,
1067                    "        // skipped: result_is_simple, field '{f}' not on simple result type"
1068                );
1069                return;
1070            }
1071        }
1072    }
1073
1074    let field_expr = if result_is_simple {
1075        format!("${result_var}")
1076    } else {
1077        match &assertion.field {
1078            Some(f) if !f.is_empty() => field_resolver.accessor(f, "php", &format!("${result_var}")),
1079            _ => format!("${result_var}"),
1080        }
1081    };
1082
1083    // For string equality, trim trailing whitespace to handle trailing newlines.
1084    let trimmed_field_expr = if result_is_simple {
1085        format!("trim(${result_var})")
1086    } else {
1087        field_expr.clone()
1088    };
1089
1090    match assertion.assertion_type.as_str() {
1091        "equals" => {
1092            if let Some(expected) = &assertion.value {
1093                let php_val = json_to_php(expected);
1094                let _ = writeln!(out, "        $this->assertEquals({php_val}, {trimmed_field_expr});");
1095            }
1096        }
1097        "contains" => {
1098            if let Some(expected) = &assertion.value {
1099                let php_val = json_to_php(expected);
1100                let _ = writeln!(
1101                    out,
1102                    "        $this->assertStringContainsString({php_val}, {field_expr});"
1103                );
1104            }
1105        }
1106        "contains_all" => {
1107            if let Some(values) = &assertion.values {
1108                for val in values {
1109                    let php_val = json_to_php(val);
1110                    let _ = writeln!(
1111                        out,
1112                        "        $this->assertStringContainsString({php_val}, {field_expr});"
1113                    );
1114                }
1115            }
1116        }
1117        "not_contains" => {
1118            if let Some(expected) = &assertion.value {
1119                let php_val = json_to_php(expected);
1120                let _ = writeln!(
1121                    out,
1122                    "        $this->assertStringNotContainsString({php_val}, {field_expr});"
1123                );
1124            }
1125        }
1126        "not_empty" => {
1127            let _ = writeln!(out, "        $this->assertNotEmpty({field_expr});");
1128        }
1129        "is_empty" => {
1130            let _ = writeln!(out, "        $this->assertEmpty({trimmed_field_expr});");
1131        }
1132        "contains_any" => {
1133            if let Some(values) = &assertion.values {
1134                let _ = writeln!(out, "        $found = false;");
1135                for val in values {
1136                    let php_val = json_to_php(val);
1137                    let _ = writeln!(
1138                        out,
1139                        "        if (str_contains({field_expr}, {php_val})) {{ $found = true; }}"
1140                    );
1141                }
1142                let _ = writeln!(
1143                    out,
1144                    "        $this->assertTrue($found, 'expected to contain at least one of the specified values');"
1145                );
1146            }
1147        }
1148        "greater_than" => {
1149            if let Some(val) = &assertion.value {
1150                let php_val = json_to_php(val);
1151                let _ = writeln!(out, "        $this->assertGreaterThan({php_val}, {field_expr});");
1152            }
1153        }
1154        "less_than" => {
1155            if let Some(val) = &assertion.value {
1156                let php_val = json_to_php(val);
1157                let _ = writeln!(out, "        $this->assertLessThan({php_val}, {field_expr});");
1158            }
1159        }
1160        "greater_than_or_equal" => {
1161            if let Some(val) = &assertion.value {
1162                let php_val = json_to_php(val);
1163                let _ = writeln!(out, "        $this->assertGreaterThanOrEqual({php_val}, {field_expr});");
1164            }
1165        }
1166        "less_than_or_equal" => {
1167            if let Some(val) = &assertion.value {
1168                let php_val = json_to_php(val);
1169                let _ = writeln!(out, "        $this->assertLessThanOrEqual({php_val}, {field_expr});");
1170            }
1171        }
1172        "starts_with" => {
1173            if let Some(expected) = &assertion.value {
1174                let php_val = json_to_php(expected);
1175                let _ = writeln!(out, "        $this->assertStringStartsWith({php_val}, {field_expr});");
1176            }
1177        }
1178        "ends_with" => {
1179            if let Some(expected) = &assertion.value {
1180                let php_val = json_to_php(expected);
1181                let _ = writeln!(out, "        $this->assertStringEndsWith({php_val}, {field_expr});");
1182            }
1183        }
1184        "min_length" => {
1185            if let Some(val) = &assertion.value {
1186                if let Some(n) = val.as_u64() {
1187                    let _ = writeln!(
1188                        out,
1189                        "        $this->assertGreaterThanOrEqual({n}, strlen({field_expr}));"
1190                    );
1191                }
1192            }
1193        }
1194        "max_length" => {
1195            if let Some(val) = &assertion.value {
1196                if let Some(n) = val.as_u64() {
1197                    let _ = writeln!(out, "        $this->assertLessThanOrEqual({n}, strlen({field_expr}));");
1198                }
1199            }
1200        }
1201        "count_min" => {
1202            if let Some(val) = &assertion.value {
1203                if let Some(n) = val.as_u64() {
1204                    let _ = writeln!(
1205                        out,
1206                        "        $this->assertGreaterThanOrEqual({n}, count({field_expr}));"
1207                    );
1208                }
1209            }
1210        }
1211        "count_equals" => {
1212            if let Some(val) = &assertion.value {
1213                if let Some(n) = val.as_u64() {
1214                    let _ = writeln!(out, "        $this->assertCount({n}, {field_expr});");
1215                }
1216            }
1217        }
1218        "is_true" => {
1219            let _ = writeln!(out, "        $this->assertTrue({field_expr});");
1220        }
1221        "is_false" => {
1222            let _ = writeln!(out, "        $this->assertFalse({field_expr});");
1223        }
1224        "method_result" => {
1225            if let Some(method_name) = &assertion.method {
1226                let call_expr = build_php_method_call(result_var, method_name, assertion.args.as_ref());
1227                let check = assertion.check.as_deref().unwrap_or("is_true");
1228                match check {
1229                    "equals" => {
1230                        if let Some(val) = &assertion.value {
1231                            if val.is_boolean() {
1232                                if val.as_bool() == Some(true) {
1233                                    let _ = writeln!(out, "        $this->assertTrue({call_expr});");
1234                                } else {
1235                                    let _ = writeln!(out, "        $this->assertFalse({call_expr});");
1236                                }
1237                            } else {
1238                                let expected = json_to_php(val);
1239                                let _ = writeln!(out, "        $this->assertEquals({expected}, {call_expr});");
1240                            }
1241                        }
1242                    }
1243                    "is_true" => {
1244                        let _ = writeln!(out, "        $this->assertTrue({call_expr});");
1245                    }
1246                    "is_false" => {
1247                        let _ = writeln!(out, "        $this->assertFalse({call_expr});");
1248                    }
1249                    "greater_than_or_equal" => {
1250                        if let Some(val) = &assertion.value {
1251                            let n = val.as_u64().unwrap_or(0);
1252                            let _ = writeln!(out, "        $this->assertGreaterThanOrEqual({n}, {call_expr});");
1253                        }
1254                    }
1255                    "count_min" => {
1256                        if let Some(val) = &assertion.value {
1257                            let n = val.as_u64().unwrap_or(0);
1258                            let _ = writeln!(out, "        $this->assertGreaterThanOrEqual({n}, count({call_expr}));");
1259                        }
1260                    }
1261                    "is_error" => {
1262                        let _ = writeln!(out, "        $this->expectException(\\Exception::class);");
1263                        let _ = writeln!(out, "        {call_expr};");
1264                    }
1265                    "contains" => {
1266                        if let Some(val) = &assertion.value {
1267                            let expected = json_to_php(val);
1268                            let _ = writeln!(
1269                                out,
1270                                "        $this->assertStringContainsString({expected}, {call_expr});"
1271                            );
1272                        }
1273                    }
1274                    other_check => {
1275                        panic!("PHP e2e generator: unsupported method_result check type: {other_check}");
1276                    }
1277                }
1278            } else {
1279                panic!("PHP e2e generator: method_result assertion missing 'method' field");
1280            }
1281        }
1282        "matches_regex" => {
1283            if let Some(expected) = &assertion.value {
1284                let php_val = json_to_php(expected);
1285                let _ = writeln!(
1286                    out,
1287                    "        $this->assertMatchesRegularExpression({php_val}, {field_expr});"
1288                );
1289            }
1290        }
1291        "not_error" => {
1292            // Already handled by the call succeeding without exception.
1293        }
1294        "error" => {
1295            // Handled at the test method level.
1296        }
1297        other => {
1298            panic!("PHP e2e generator: unsupported assertion type: {other}");
1299        }
1300    }
1301}
1302
1303/// Build a PHP call expression for a `method_result` assertion on a tree-sitter `Tree`.
1304///
1305/// Maps method names to the appropriate PHP static function calls on the
1306/// `TreeSitterLanguagePack` class (using the ext-php-rs snake_case method names).
1307fn build_php_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
1308    match method_name {
1309        "root_child_count" => {
1310            format!("count(TreeSitterLanguagePack::named_children_info(${result_var}))")
1311        }
1312        "root_node_type" => {
1313            format!("TreeSitterLanguagePack::root_node_info(${result_var})->kind")
1314        }
1315        "named_children_count" => {
1316            format!("count(TreeSitterLanguagePack::named_children_info(${result_var}))")
1317        }
1318        "has_error_nodes" => {
1319            format!("TreeSitterLanguagePack::tree_has_error_nodes(${result_var})")
1320        }
1321        "error_count" | "tree_error_count" => {
1322            format!("TreeSitterLanguagePack::tree_error_count(${result_var})")
1323        }
1324        "tree_to_sexp" => {
1325            format!("TreeSitterLanguagePack::tree_to_sexp(${result_var})")
1326        }
1327        "contains_node_type" => {
1328            let node_type = args
1329                .and_then(|a| a.get("node_type"))
1330                .and_then(|v| v.as_str())
1331                .unwrap_or("");
1332            format!("TreeSitterLanguagePack::tree_contains_node_type(${result_var}, \"{node_type}\")")
1333        }
1334        "find_nodes_by_type" => {
1335            let node_type = args
1336                .and_then(|a| a.get("node_type"))
1337                .and_then(|v| v.as_str())
1338                .unwrap_or("");
1339            format!("TreeSitterLanguagePack::find_nodes_by_type(${result_var}, \"{node_type}\")")
1340        }
1341        "run_query" => {
1342            let query_source = args
1343                .and_then(|a| a.get("query_source"))
1344                .and_then(|v| v.as_str())
1345                .unwrap_or("");
1346            let language = args
1347                .and_then(|a| a.get("language"))
1348                .and_then(|v| v.as_str())
1349                .unwrap_or("");
1350            format!("TreeSitterLanguagePack::run_query(${result_var}, \"{language}\", \"{query_source}\", $source)")
1351        }
1352        _ => {
1353            format!("${result_var}->{method_name}()")
1354        }
1355    }
1356}
1357
1358/// Convert a `serde_json::Value` to a PHP literal string.
1359fn json_to_php(value: &serde_json::Value) -> String {
1360    match value {
1361        serde_json::Value::String(s) => format!("\"{}\"", escape_php(s)),
1362        serde_json::Value::Bool(true) => "true".to_string(),
1363        serde_json::Value::Bool(false) => "false".to_string(),
1364        serde_json::Value::Number(n) => n.to_string(),
1365        serde_json::Value::Null => "null".to_string(),
1366        serde_json::Value::Array(arr) => {
1367            let items: Vec<String> = arr.iter().map(json_to_php).collect();
1368            format!("[{}]", items.join(", "))
1369        }
1370        serde_json::Value::Object(map) => {
1371            let items: Vec<String> = map
1372                .iter()
1373                .map(|(k, v)| format!("\"{}\" => {}", escape_php(k), json_to_php(v)))
1374                .collect();
1375            format!("[{}]", items.join(", "))
1376        }
1377    }
1378}
1379
1380// ---------------------------------------------------------------------------
1381// Visitor generation
1382// ---------------------------------------------------------------------------
1383
1384/// Build a PHP visitor object and add setup lines. Returns the visitor expression.
1385fn build_php_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1386    setup_lines.push("$visitor = new class {".to_string());
1387    for (method_name, action) in &visitor_spec.callbacks {
1388        emit_php_visitor_method(setup_lines, method_name, action);
1389    }
1390    setup_lines.push("};".to_string());
1391    "$visitor".to_string()
1392}
1393
1394/// Emit a PHP visitor method for a callback action.
1395fn emit_php_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
1396    let snake_method = method_name;
1397    let params = match method_name {
1398        "visit_link" => "$ctx, $href, $text, $title",
1399        "visit_image" => "$ctx, $src, $alt, $title",
1400        "visit_heading" => "$ctx, $level, $text, $id",
1401        "visit_code_block" => "$ctx, $lang, $code",
1402        "visit_code_inline"
1403        | "visit_strong"
1404        | "visit_emphasis"
1405        | "visit_strikethrough"
1406        | "visit_underline"
1407        | "visit_subscript"
1408        | "visit_superscript"
1409        | "visit_mark"
1410        | "visit_button"
1411        | "visit_summary"
1412        | "visit_figcaption"
1413        | "visit_definition_term"
1414        | "visit_definition_description" => "$ctx, $text",
1415        "visit_text" => "$ctx, $text",
1416        "visit_list_item" => "$ctx, $ordered, $marker, $text",
1417        "visit_blockquote" => "$ctx, $content, $depth",
1418        "visit_table_row" => "$ctx, $cells, $isHeader",
1419        "visit_custom_element" => "$ctx, $tagName, $html",
1420        "visit_form" => "$ctx, $actionUrl, $method",
1421        "visit_input" => "$ctx, $inputType, $name, $value",
1422        "visit_audio" | "visit_video" | "visit_iframe" => "$ctx, $src",
1423        "visit_details" => "$ctx, $isOpen",
1424        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "$ctx, $output",
1425        "visit_list_start" => "$ctx, $ordered",
1426        "visit_list_end" => "$ctx, $ordered, $output",
1427        _ => "$ctx",
1428    };
1429
1430    setup_lines.push(format!("    public function {snake_method}({params}) {{"));
1431    match action {
1432        CallbackAction::Skip => {
1433            setup_lines.push("        return 'skip';".to_string());
1434        }
1435        CallbackAction::Continue => {
1436            setup_lines.push("        return 'continue';".to_string());
1437        }
1438        CallbackAction::PreserveHtml => {
1439            setup_lines.push("        return 'preserve_html';".to_string());
1440        }
1441        CallbackAction::Custom { output } => {
1442            let escaped = escape_php(output);
1443            setup_lines.push(format!("        return ['custom' => {escaped}];"));
1444        }
1445        CallbackAction::CustomTemplate { template } => {
1446            let escaped = escape_php(template);
1447            setup_lines.push(format!("        return ['custom' => {escaped}];"));
1448        }
1449    }
1450    setup_lines.push("    }".to_string());
1451}