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