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};
11use alef_core::backend::GeneratedFile;
12use alef_core::config::AlefConfig;
13use anyhow::Result;
14use heck::{ToSnakeCase, ToUpperCamelCase};
15use std::collections::HashMap;
16use std::fmt::Write as FmtWrite;
17use std::path::PathBuf;
18
19use super::E2eCodegen;
20
21/// PHP e2e code generator.
22pub struct PhpCodegen;
23
24impl E2eCodegen for PhpCodegen {
25    fn generate(
26        &self,
27        groups: &[FixtureGroup],
28        e2e_config: &E2eConfig,
29        alef_config: &AlefConfig,
30    ) -> Result<Vec<GeneratedFile>> {
31        let lang = self.language_name();
32        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
33
34        let mut files = Vec::new();
35
36        // Resolve top-level call config to derive class/namespace/factory — these are
37        // shared across all categories. Per-fixture call routing (function name, args)
38        // is resolved inside render_test_method via e2e_config.resolve_call().
39        let call = &e2e_config.call;
40        let overrides = call.overrides.get(lang);
41        let extension_name = alef_config.php_extension_name();
42        let class_name = overrides
43            .and_then(|o| o.class.as_ref())
44            .cloned()
45            .unwrap_or_else(|| extension_name.to_upper_camel_case());
46        let namespace = overrides.and_then(|o| o.module.as_ref()).cloned().unwrap_or_else(|| {
47            if extension_name.contains('_') {
48                extension_name
49                    .split('_')
50                    .map(|p| p.to_upper_camel_case())
51                    .collect::<Vec<_>>()
52                    .join("\\")
53            } else {
54                extension_name.to_upper_camel_case()
55            }
56        });
57        let empty_enum_fields = HashMap::new();
58        let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
59        let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
60        let php_client_factory = overrides.and_then(|o| o.php_client_factory.as_deref());
61        let options_via = overrides.and_then(|o| o.options_via.as_deref()).unwrap_or("array");
62
63        // Resolve package config.
64        let php_pkg = e2e_config.resolve_package("php");
65        let pkg_name = php_pkg
66            .as_ref()
67            .and_then(|p| p.name.as_ref())
68            .cloned()
69            .unwrap_or_else(|| format!("kreuzberg/{}", call.module.replace('_', "-")));
70        let pkg_path = php_pkg
71            .as_ref()
72            .and_then(|p| p.path.as_ref())
73            .cloned()
74            .unwrap_or_else(|| "../../packages/php".to_string());
75        let pkg_version = php_pkg
76            .as_ref()
77            .and_then(|p| p.version.as_ref())
78            .cloned()
79            .unwrap_or_else(|| "0.1.0".to_string());
80
81        // Generate composer.json.
82        files.push(GeneratedFile {
83            path: output_base.join("composer.json"),
84            content: render_composer_json(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
85            generated_header: false,
86        });
87
88        // Generate phpunit.xml.
89        files.push(GeneratedFile {
90            path: output_base.join("phpunit.xml"),
91            content: render_phpunit_xml(),
92            generated_header: false,
93        });
94
95        // Generate bootstrap.php that loads both autoloaders.
96        files.push(GeneratedFile {
97            path: output_base.join("bootstrap.php"),
98            content: render_bootstrap(&pkg_path),
99            generated_header: true,
100        });
101
102        // Generate test files per category.
103        let tests_base = output_base.join("tests");
104        let field_resolver = FieldResolver::new(
105            &e2e_config.fields,
106            &e2e_config.fields_optional,
107            &e2e_config.result_fields,
108            &e2e_config.fields_array,
109        );
110
111        for group in groups {
112            let active: Vec<&Fixture> = group
113                .fixtures
114                .iter()
115                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
116                .collect();
117
118            if active.is_empty() {
119                continue;
120            }
121
122            let test_class = format!("{}Test", sanitize_filename(&group.category).to_upper_camel_case());
123            let filename = format!("{test_class}.php");
124            let content = render_test_file(
125                &group.category,
126                &active,
127                e2e_config,
128                lang,
129                &namespace,
130                &class_name,
131                &test_class,
132                &field_resolver,
133                enum_fields,
134                result_is_simple,
135                php_client_factory,
136                options_via,
137            );
138            files.push(GeneratedFile {
139                path: tests_base.join(filename),
140                content,
141                generated_header: true,
142            });
143        }
144
145        Ok(files)
146    }
147
148    fn language_name(&self) -> &'static str {
149        "php"
150    }
151}
152
153// ---------------------------------------------------------------------------
154// Rendering
155// ---------------------------------------------------------------------------
156
157fn render_composer_json(
158    pkg_name: &str,
159    _pkg_path: &str,
160    pkg_version: &str,
161    dep_mode: crate::config::DependencyMode,
162) -> String {
163    let require_section = match dep_mode {
164        crate::config::DependencyMode::Registry => {
165            format!(
166                r#"  "require": {{
167    "{pkg_name}": "{pkg_version}"
168  }},
169  "require-dev": {{
170    "phpunit/phpunit": "^13.1"
171  }},"#
172            )
173        }
174        crate::config::DependencyMode::Local => r#"  "require-dev": {
175    "phpunit/phpunit": "^13.1"
176  },"#
177        .to_string(),
178    };
179
180    format!(
181        r#"{{
182  "name": "kreuzberg/e2e-php",
183  "description": "E2e tests for PHP bindings",
184  "type": "project",
185{require_section}
186  "autoload-dev": {{
187    "psr-4": {{
188      "Kreuzberg\\E2e\\": "tests/"
189    }}
190  }}
191}}
192"#
193    )
194}
195
196fn render_phpunit_xml() -> String {
197    r#"<?xml version="1.0" encoding="UTF-8"?>
198<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
199         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/13.1/phpunit.xsd"
200         bootstrap="bootstrap.php"
201         colors="true"
202         failOnRisky="true"
203         failOnWarning="true">
204    <testsuites>
205        <testsuite name="e2e">
206            <directory>tests</directory>
207        </testsuite>
208    </testsuites>
209</phpunit>
210"#
211    .to_string()
212}
213
214fn render_bootstrap(pkg_path: &str) -> String {
215    format!(
216        r#"<?php
217// This file is auto-generated by alef. DO NOT EDIT.
218
219declare(strict_types=1);
220
221// Load the e2e project autoloader (PHPUnit, test helpers).
222require_once __DIR__ . '/vendor/autoload.php';
223
224// Load the PHP binding package classes via its Composer autoloader.
225// The package's autoloader is separate from the e2e project's autoloader
226// since the php-ext type prevents direct composer path dependency.
227$pkgAutoloader = __DIR__ . '/{pkg_path}/vendor/autoload.php';
228if (file_exists($pkgAutoloader)) {{
229    require_once $pkgAutoloader;
230}}
231"#
232    )
233}
234
235#[allow(clippy::too_many_arguments)]
236fn render_test_file(
237    category: &str,
238    fixtures: &[&Fixture],
239    e2e_config: &E2eConfig,
240    lang: &str,
241    namespace: &str,
242    class_name: &str,
243    test_class: &str,
244    field_resolver: &FieldResolver,
245    enum_fields: &HashMap<String, String>,
246    result_is_simple: bool,
247    php_client_factory: Option<&str>,
248    options_via: &str,
249) -> String {
250    let mut out = String::new();
251    let _ = writeln!(out, "<?php");
252    let _ = writeln!(out, "// This file is auto-generated by alef. DO NOT EDIT.");
253    let _ = writeln!(out);
254    let _ = writeln!(out, "declare(strict_types=1);");
255    let _ = writeln!(out);
256    let _ = writeln!(out, "namespace Kreuzberg\\E2e;");
257    let _ = writeln!(out);
258    // Determine if any handle arg has a non-null config (needs CrawlConfig import).
259    let needs_crawl_config_import = fixtures.iter().any(|f| {
260        let call = e2e_config.resolve_call(f.call.as_deref());
261        call.args.iter().filter(|a| a.arg_type == "handle").any(|a| {
262            let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
263            !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
264        })
265    });
266
267    let _ = writeln!(out, "use PHPUnit\\Framework\\TestCase;");
268    let _ = writeln!(out, "use {namespace}\\{class_name};");
269    if needs_crawl_config_import {
270        let _ = writeln!(out, "use {namespace}\\CrawlConfig;");
271    }
272    let _ = writeln!(out);
273    let _ = writeln!(out, "/** E2e tests for category: {category}. */");
274    let _ = writeln!(out, "final class {test_class} extends TestCase");
275    let _ = writeln!(out, "{{");
276
277    for (i, fixture) in fixtures.iter().enumerate() {
278        render_test_method(
279            &mut out,
280            fixture,
281            e2e_config,
282            lang,
283            namespace,
284            class_name,
285            field_resolver,
286            enum_fields,
287            result_is_simple,
288            php_client_factory,
289            options_via,
290        );
291        if i + 1 < fixtures.len() {
292            let _ = writeln!(out);
293        }
294    }
295
296    let _ = writeln!(out, "}}");
297    out
298}
299
300#[allow(clippy::too_many_arguments)]
301fn render_test_method(
302    out: &mut String,
303    fixture: &Fixture,
304    e2e_config: &E2eConfig,
305    lang: &str,
306    namespace: &str,
307    class_name: &str,
308    field_resolver: &FieldResolver,
309    enum_fields: &HashMap<String, String>,
310    result_is_simple: bool,
311    php_client_factory: Option<&str>,
312    options_via: &str,
313) {
314    // Resolve per-fixture call config: supports named calls via fixture.call field.
315    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
316    let call_overrides = call_config.overrides.get(lang);
317    let mut function_name = call_overrides
318        .and_then(|o| o.function.as_ref())
319        .cloned()
320        .unwrap_or_else(|| call_config.function.clone());
321    // PHP ext-php-rs async methods have an _async suffix.
322    if call_config.r#async {
323        function_name = format!("{function_name}_async");
324    }
325    let result_var = &call_config.result_var;
326    let args = &call_config.args;
327
328    let method_name = sanitize_filename(&fixture.id);
329    let description = &fixture.description;
330    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
331
332    let (mut setup_lines, args_str) =
333        build_args_and_setup(&fixture.input, args, class_name, enum_fields, &fixture.id, options_via);
334
335    // Build visitor if present and add to setup
336    let mut visitor_arg = String::new();
337    if let Some(visitor_spec) = &fixture.visitor {
338        visitor_arg = build_php_visitor(&mut setup_lines, visitor_spec);
339    }
340
341    let final_args = if visitor_arg.is_empty() {
342        args_str
343    } else if args_str.is_empty() {
344        visitor_arg
345    } else {
346        format!("{args_str}, {visitor_arg}")
347    };
348
349    let call_expr = if php_client_factory.is_some() {
350        format!("$client->{function_name}({final_args})")
351    } else {
352        format!("{class_name}::{function_name}({final_args})")
353    };
354
355    let _ = writeln!(out, "    /** {description} */");
356    let _ = writeln!(out, "    public function test_{method_name}(): void");
357    let _ = writeln!(out, "    {{");
358
359    if let Some(factory) = php_client_factory {
360        let _ = writeln!(
361            out,
362            "        $client = \\{namespace}\\{class_name}::{factory}('test-key');"
363        );
364    }
365
366    for line in &setup_lines {
367        let _ = writeln!(out, "        {line}");
368    }
369
370    if expects_error {
371        let _ = writeln!(out, "        $this->expectException(\\Exception::class);");
372        let _ = writeln!(out, "        {call_expr};");
373        let _ = writeln!(out, "    }}");
374        return;
375    }
376
377    // If no assertion will actually produce a PHPUnit assert call, mark the test
378    // as intentionally assertion-free so PHPUnit does not flag it as risky.
379    let has_usable = fixture.assertions.iter().any(|a| {
380        if a.assertion_type == "error" || a.assertion_type == "not_error" {
381            return false;
382        }
383        match &a.field {
384            Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
385            _ => true,
386        }
387    });
388    if !has_usable {
389        let _ = writeln!(out, "        $this->expectNotToPerformAssertions();");
390    }
391
392    let _ = writeln!(out, "        ${result_var} = {call_expr};");
393
394    for assertion in &fixture.assertions {
395        render_assertion(out, assertion, result_var, field_resolver, result_is_simple);
396    }
397
398    let _ = writeln!(out, "    }}");
399}
400
401/// Build setup lines (e.g. handle creation) and the argument list for the function call.
402///
403/// `options_via` controls how `json_object` args are passed:
404/// - `"array"` (default): PHP array literal `["key" => value, ...]`
405/// - `"json"`: JSON string via `json_encode([...])` — use when the Rust method accepts `Option<String>`
406///
407/// Returns `(setup_lines, args_string)`.
408fn build_args_and_setup(
409    input: &serde_json::Value,
410    args: &[crate::config::ArgMapping],
411    class_name: &str,
412    enum_fields: &HashMap<String, String>,
413    fixture_id: &str,
414    options_via: &str,
415) -> (Vec<String>, String) {
416    if args.is_empty() {
417        // No args configuration: pass the whole input only if it's non-empty.
418        // Functions with no parameters (e.g. list_models) have empty input and get no args.
419        let is_empty_input = match input {
420            serde_json::Value::Null => true,
421            serde_json::Value::Object(m) => m.is_empty(),
422            _ => false,
423        };
424        if is_empty_input {
425            return (Vec::new(), String::new());
426        }
427        return (Vec::new(), json_to_php(input));
428    }
429
430    let mut setup_lines: Vec<String> = Vec::new();
431    let mut parts: Vec<String> = Vec::new();
432
433    for arg in args {
434        if arg.arg_type == "mock_url" {
435            setup_lines.push(format!(
436                "${} = getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}';",
437                arg.name,
438            ));
439            parts.push(format!("${}", arg.name));
440            continue;
441        }
442
443        if arg.arg_type == "handle" {
444            // Generate a createEngine (or equivalent) call and pass the variable.
445            let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
446            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
447            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
448            if config_value.is_null()
449                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
450            {
451                setup_lines.push(format!("${} = {class_name}::{constructor_name}(null);", arg.name,));
452            } else {
453                let name = &arg.name;
454                // Build a CrawlConfig object and set its fields via property assignment.
455                // The PHP binding accepts `?CrawlConfig $config` — there is no JSON string
456                // variant. Object and array config values are expressed as PHP array literals.
457                setup_lines.push(format!("${name}_config = CrawlConfig::default();"));
458                if let Some(obj) = config_value.as_object() {
459                    for (key, val) in obj {
460                        let php_val = json_to_php(val);
461                        setup_lines.push(format!("${name}_config->{key} = {php_val};"));
462                    }
463                }
464                setup_lines.push(format!(
465                    "${} = {class_name}::{constructor_name}(${name}_config);",
466                    arg.name,
467                ));
468            }
469            parts.push(format!("${}", arg.name));
470            continue;
471        }
472
473        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
474        let val = input.get(field);
475        match val {
476            None | Some(serde_json::Value::Null) if arg.optional => {
477                // Optional arg with no fixture value: skip entirely.
478                continue;
479            }
480            None | Some(serde_json::Value::Null) => {
481                // Required arg with no fixture value: pass a language-appropriate default.
482                let default_val = match arg.arg_type.as_str() {
483                    "string" => "\"\"".to_string(),
484                    "int" | "integer" => "0".to_string(),
485                    "float" | "number" => "0.0".to_string(),
486                    "bool" | "boolean" => "false".to_string(),
487                    "json_object" if options_via == "json" => "null".to_string(),
488                    _ => "null".to_string(),
489                };
490                parts.push(default_val);
491            }
492            Some(v) => {
493                if arg.arg_type == "json_object" && !v.is_null() {
494                    match options_via {
495                        "json" => {
496                            // Pass as JSON string via json_encode(); the Rust method accepts Option<String>.
497                            parts.push(format!("json_encode({})", json_to_php(v)));
498                            continue;
499                        }
500                        _ => {
501                            // Default: PHP array literal with snake_case keys.
502                            if let Some(obj) = v.as_object() {
503                                let items: Vec<String> = obj
504                                    .iter()
505                                    .map(|(k, vv)| {
506                                        let snake_key = k.to_snake_case();
507                                        let php_val = if enum_fields.contains_key(k) {
508                                            if let Some(s) = vv.as_str() {
509                                                let snake_val = s.to_snake_case();
510                                                format!("\"{}\"", escape_php(&snake_val))
511                                            } else {
512                                                json_to_php(vv)
513                                            }
514                                        } else {
515                                            json_to_php(vv)
516                                        };
517                                        format!("\"{}\" => {}", escape_php(&snake_key), php_val)
518                                    })
519                                    .collect();
520                                parts.push(format!("[{}]", items.join(", ")));
521                                continue;
522                            }
523                        }
524                    }
525                }
526                parts.push(json_to_php(v));
527            }
528        }
529    }
530
531    (setup_lines, parts.join(", "))
532}
533
534fn render_assertion(
535    out: &mut String,
536    assertion: &Assertion,
537    result_var: &str,
538    field_resolver: &FieldResolver,
539    result_is_simple: bool,
540) {
541    // Skip assertions on fields that don't exist on the result type.
542    if let Some(f) = &assertion.field {
543        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
544            let _ = writeln!(out, "        // skipped: field '{f}' not available on result type");
545            return;
546        }
547    }
548
549    // When result_is_simple, skip assertions that reference non-content fields
550    // (e.g., metadata, document, structure) since the binding returns a plain value.
551    if result_is_simple {
552        if let Some(f) = &assertion.field {
553            let f_lower = f.to_lowercase();
554            if !f.is_empty()
555                && f_lower != "content"
556                && (f_lower.starts_with("metadata")
557                    || f_lower.starts_with("document")
558                    || f_lower.starts_with("structure"))
559            {
560                let _ = writeln!(out, "        // TODO: skipped (result_is_simple, field: {f})");
561                return;
562            }
563        }
564    }
565
566    let field_expr = if result_is_simple {
567        format!("${result_var}")
568    } else {
569        match &assertion.field {
570            Some(f) if !f.is_empty() => field_resolver.accessor(f, "php", &format!("${result_var}")),
571            _ => format!("${result_var}"),
572        }
573    };
574
575    // For string equality, trim trailing whitespace to handle trailing newlines.
576    let trimmed_field_expr = if result_is_simple {
577        format!("trim(${result_var})")
578    } else {
579        field_expr.clone()
580    };
581
582    match assertion.assertion_type.as_str() {
583        "equals" => {
584            if let Some(expected) = &assertion.value {
585                let php_val = json_to_php(expected);
586                let _ = writeln!(out, "        $this->assertEquals({php_val}, {trimmed_field_expr});");
587            }
588        }
589        "contains" => {
590            if let Some(expected) = &assertion.value {
591                let php_val = json_to_php(expected);
592                let _ = writeln!(
593                    out,
594                    "        $this->assertStringContainsString({php_val}, {field_expr});"
595                );
596            }
597        }
598        "contains_all" => {
599            if let Some(values) = &assertion.values {
600                for val in values {
601                    let php_val = json_to_php(val);
602                    let _ = writeln!(
603                        out,
604                        "        $this->assertStringContainsString({php_val}, {field_expr});"
605                    );
606                }
607            }
608        }
609        "not_contains" => {
610            if let Some(expected) = &assertion.value {
611                let php_val = json_to_php(expected);
612                let _ = writeln!(
613                    out,
614                    "        $this->assertStringNotContainsString({php_val}, {field_expr});"
615                );
616            }
617        }
618        "not_empty" => {
619            let _ = writeln!(out, "        $this->assertNotEmpty({field_expr});");
620        }
621        "is_empty" => {
622            let _ = writeln!(out, "        $this->assertEmpty({trimmed_field_expr});");
623        }
624        "contains_any" => {
625            if let Some(values) = &assertion.values {
626                let _ = writeln!(out, "        $found = false;");
627                for val in values {
628                    let php_val = json_to_php(val);
629                    let _ = writeln!(
630                        out,
631                        "        if (str_contains({field_expr}, {php_val})) {{ $found = true; }}"
632                    );
633                }
634                let _ = writeln!(
635                    out,
636                    "        $this->assertTrue($found, 'expected to contain at least one of the specified values');"
637                );
638            }
639        }
640        "greater_than" => {
641            if let Some(val) = &assertion.value {
642                let php_val = json_to_php(val);
643                let _ = writeln!(out, "        $this->assertGreaterThan({php_val}, {field_expr});");
644            }
645        }
646        "less_than" => {
647            if let Some(val) = &assertion.value {
648                let php_val = json_to_php(val);
649                let _ = writeln!(out, "        $this->assertLessThan({php_val}, {field_expr});");
650            }
651        }
652        "greater_than_or_equal" => {
653            if let Some(val) = &assertion.value {
654                let php_val = json_to_php(val);
655                let _ = writeln!(out, "        $this->assertGreaterThanOrEqual({php_val}, {field_expr});");
656            }
657        }
658        "less_than_or_equal" => {
659            if let Some(val) = &assertion.value {
660                let php_val = json_to_php(val);
661                let _ = writeln!(out, "        $this->assertLessThanOrEqual({php_val}, {field_expr});");
662            }
663        }
664        "starts_with" => {
665            if let Some(expected) = &assertion.value {
666                let php_val = json_to_php(expected);
667                let _ = writeln!(out, "        $this->assertStringStartsWith({php_val}, {field_expr});");
668            }
669        }
670        "ends_with" => {
671            if let Some(expected) = &assertion.value {
672                let php_val = json_to_php(expected);
673                let _ = writeln!(out, "        $this->assertStringEndsWith({php_val}, {field_expr});");
674            }
675        }
676        "min_length" => {
677            if let Some(val) = &assertion.value {
678                if let Some(n) = val.as_u64() {
679                    let _ = writeln!(
680                        out,
681                        "        $this->assertGreaterThanOrEqual({n}, strlen({field_expr}));"
682                    );
683                }
684            }
685        }
686        "max_length" => {
687            if let Some(val) = &assertion.value {
688                if let Some(n) = val.as_u64() {
689                    let _ = writeln!(out, "        $this->assertLessThanOrEqual({n}, strlen({field_expr}));");
690                }
691            }
692        }
693        "count_min" => {
694            if let Some(val) = &assertion.value {
695                if let Some(n) = val.as_u64() {
696                    let _ = writeln!(
697                        out,
698                        "        $this->assertGreaterThanOrEqual({n}, count({field_expr}));"
699                    );
700                }
701            }
702        }
703        "count_equals" => {
704            if let Some(val) = &assertion.value {
705                if let Some(n) = val.as_u64() {
706                    let _ = writeln!(out, "        $this->assertCount({n}, {field_expr});");
707                }
708            }
709        }
710        "is_true" => {
711            let _ = writeln!(out, "        $this->assertTrue({field_expr});");
712        }
713        "not_error" => {
714            // Already handled by the call succeeding without exception.
715        }
716        "error" => {
717            // Handled at the test method level.
718        }
719        other => {
720            let _ = writeln!(out, "        // TODO: unsupported assertion type: {other}");
721        }
722    }
723}
724
725/// Convert a `serde_json::Value` to a PHP literal string.
726fn json_to_php(value: &serde_json::Value) -> String {
727    match value {
728        serde_json::Value::String(s) => format!("\"{}\"", escape_php(s)),
729        serde_json::Value::Bool(true) => "true".to_string(),
730        serde_json::Value::Bool(false) => "false".to_string(),
731        serde_json::Value::Number(n) => n.to_string(),
732        serde_json::Value::Null => "null".to_string(),
733        serde_json::Value::Array(arr) => {
734            let items: Vec<String> = arr.iter().map(json_to_php).collect();
735            format!("[{}]", items.join(", "))
736        }
737        serde_json::Value::Object(map) => {
738            let items: Vec<String> = map
739                .iter()
740                .map(|(k, v)| format!("\"{}\" => {}", escape_php(k), json_to_php(v)))
741                .collect();
742            format!("[{}]", items.join(", "))
743        }
744    }
745}
746
747// ---------------------------------------------------------------------------
748// Visitor generation
749// ---------------------------------------------------------------------------
750
751/// Build a PHP visitor object and add setup lines. Returns the visitor expression.
752fn build_php_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
753    setup_lines.push("$visitor = new class {".to_string());
754    for (method_name, action) in &visitor_spec.callbacks {
755        emit_php_visitor_method(setup_lines, method_name, action);
756    }
757    setup_lines.push("};".to_string());
758    "$visitor".to_string()
759}
760
761/// Emit a PHP visitor method for a callback action.
762fn emit_php_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
763    let snake_method = method_name;
764    let params = match method_name {
765        "visit_link" => "$ctx, $href, $text, $title",
766        "visit_image" => "$ctx, $src, $alt, $title",
767        "visit_heading" => "$ctx, $level, $text, $id",
768        "visit_code_block" => "$ctx, $lang, $code",
769        "visit_code_inline"
770        | "visit_strong"
771        | "visit_emphasis"
772        | "visit_strikethrough"
773        | "visit_underline"
774        | "visit_subscript"
775        | "visit_superscript"
776        | "visit_mark"
777        | "visit_button"
778        | "visit_summary"
779        | "visit_figcaption"
780        | "visit_definition_term"
781        | "visit_definition_description" => "$ctx, $text",
782        "visit_text" => "$ctx, $text",
783        "visit_list_item" => "$ctx, $ordered, $marker, $text",
784        "visit_blockquote" => "$ctx, $content, $depth",
785        "visit_table_row" => "$ctx, $cells, $isHeader",
786        "visit_custom_element" => "$ctx, $tagName, $html",
787        "visit_form" => "$ctx, $actionUrl, $method",
788        "visit_input" => "$ctx, $inputType, $name, $value",
789        "visit_audio" | "visit_video" | "visit_iframe" => "$ctx, $src",
790        "visit_details" => "$ctx, $isOpen",
791        _ => "$ctx",
792    };
793
794    setup_lines.push(format!("    public function {snake_method}({params}) {{"));
795    match action {
796        CallbackAction::Skip => {
797            setup_lines.push("        return 'skip';".to_string());
798        }
799        CallbackAction::Continue => {
800            setup_lines.push("        return 'continue';".to_string());
801        }
802        CallbackAction::PreserveHtml => {
803            setup_lines.push("        return 'preserve_html';".to_string());
804        }
805        CallbackAction::Custom { output } => {
806            let escaped = escape_php(output);
807            setup_lines.push(format!("        return ['custom' => {escaped}];"));
808        }
809        CallbackAction::CustomTemplate { template } => {
810            setup_lines.push(format!("        return ['custom' => \"{template}\"];"));
811        }
812    }
813    setup_lines.push("    }".to_string());
814}