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