Skip to main content

alef_e2e/codegen/
java.rs

1//! Java e2e test generator using JUnit 5.
2//!
3//! Generates `e2e/java/pom.xml` and `src/test/java/dev/kreuzberg/e2e/{Category}Test.java`
4//! files from JSON fixtures, driven entirely by `E2eConfig` and `CallConfig`.
5
6use crate::config::E2eConfig;
7use crate::escape::{escape_java, sanitize_filename};
8use crate::field_access::FieldResolver;
9use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::AlefConfig;
12use alef_core::hash::{self, CommentStyle};
13use alef_core::template_versions as tv;
14use anyhow::Result;
15use heck::{ToLowerCamelCase, ToUpperCamelCase};
16use std::collections::HashSet;
17use std::fmt::Write as FmtWrite;
18use std::path::PathBuf;
19
20use super::E2eCodegen;
21
22/// Java e2e code generator.
23pub struct JavaCodegen;
24
25impl E2eCodegen for JavaCodegen {
26    fn generate(
27        &self,
28        groups: &[FixtureGroup],
29        e2e_config: &E2eConfig,
30        alef_config: &AlefConfig,
31    ) -> Result<Vec<GeneratedFile>> {
32        let lang = self.language_name();
33        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
34
35        let mut files = Vec::new();
36
37        // Resolve call config with overrides.
38        let call = &e2e_config.call;
39        let overrides = call.overrides.get(lang);
40        let _module_path = overrides
41            .and_then(|o| o.module.as_ref())
42            .cloned()
43            .unwrap_or_else(|| call.module.clone());
44        let function_name = overrides
45            .and_then(|o| o.function.as_ref())
46            .cloned()
47            .unwrap_or_else(|| call.function.clone());
48        let class_name = overrides
49            .and_then(|o| o.class.as_ref())
50            .cloned()
51            .unwrap_or_else(|| alef_config.crate_config.name.to_upper_camel_case());
52        let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
53        let result_var = &call.result_var;
54
55        // Resolve package config.
56        let java_pkg = e2e_config.resolve_package("java");
57        let pkg_name = java_pkg
58            .as_ref()
59            .and_then(|p| p.name.as_ref())
60            .cloned()
61            .unwrap_or_else(|| alef_config.crate_config.name.clone());
62
63        // Resolve Java package info for the dependency.
64        let java_group_id = alef_config.java_group_id();
65        let pkg_version = alef_config.resolved_version().unwrap_or_else(|| "0.1.0".to_string());
66
67        // Generate pom.xml.
68        files.push(GeneratedFile {
69            path: output_base.join("pom.xml"),
70            content: render_pom_xml(&pkg_name, &java_group_id, &pkg_version, e2e_config.dep_mode),
71            generated_header: false,
72        });
73
74        // Generate test files per category. Path mirrors the configured Java
75        // package — `dev.spikard` becomes `dev/spikard`, etc. — so the package
76        // declaration in each test file matches its filesystem location.
77        let mut test_base = output_base.join("src").join("test").join("java");
78        for segment in java_group_id.split('.') {
79            test_base = test_base.join(segment);
80        }
81        let test_base = test_base.join("e2e");
82
83        // Resolve options_type from override.
84        let options_type = overrides.and_then(|o| o.options_type.clone());
85        let field_resolver = FieldResolver::new(
86            &e2e_config.fields,
87            &e2e_config.fields_optional,
88            &e2e_config.result_fields,
89            &e2e_config.fields_array,
90        );
91
92        for group in groups {
93            let active: Vec<&Fixture> = group
94                .fixtures
95                .iter()
96                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
97                .collect();
98
99            if active.is_empty() {
100                continue;
101            }
102
103            let class_file_name = format!("{}Test.java", sanitize_filename(&group.category).to_upper_camel_case());
104            let content = render_test_file(
105                &group.category,
106                &active,
107                &class_name,
108                &function_name,
109                &java_group_id,
110                result_var,
111                &e2e_config.call.args,
112                options_type.as_deref(),
113                &field_resolver,
114                result_is_simple,
115                &e2e_config.fields_enum,
116                e2e_config,
117            );
118            files.push(GeneratedFile {
119                path: test_base.join(class_file_name),
120                content,
121                generated_header: true,
122            });
123        }
124
125        Ok(files)
126    }
127
128    fn language_name(&self) -> &'static str {
129        "java"
130    }
131}
132
133// ---------------------------------------------------------------------------
134// Rendering
135// ---------------------------------------------------------------------------
136
137fn render_pom_xml(
138    pkg_name: &str,
139    java_group_id: &str,
140    pkg_version: &str,
141    dep_mode: crate::config::DependencyMode,
142) -> String {
143    // pkg_name may be in "groupId:artifactId" Maven format; split accordingly.
144    let (dep_group_id, dep_artifact_id) = if let Some((g, a)) = pkg_name.split_once(':') {
145        (g, a)
146    } else {
147        (java_group_id, pkg_name)
148    };
149    let artifact_id = format!("{dep_artifact_id}-e2e-java");
150    let dep_block = match dep_mode {
151        crate::config::DependencyMode::Registry => {
152            format!(
153                r#"        <dependency>
154            <groupId>{dep_group_id}</groupId>
155            <artifactId>{dep_artifact_id}</artifactId>
156            <version>{pkg_version}</version>
157        </dependency>"#
158            )
159        }
160        crate::config::DependencyMode::Local => {
161            format!(
162                r#"        <dependency>
163            <groupId>{dep_group_id}</groupId>
164            <artifactId>{dep_artifact_id}</artifactId>
165            <version>{pkg_version}</version>
166            <scope>system</scope>
167            <systemPath>${{project.basedir}}/../../packages/java/target/{dep_artifact_id}-{pkg_version}.jar</systemPath>
168        </dependency>"#
169            )
170        }
171    };
172    format!(
173        r#"<?xml version="1.0" encoding="UTF-8"?>
174<project xmlns="http://maven.apache.org/POM/4.0.0"
175         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
176         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
177    <modelVersion>4.0.0</modelVersion>
178
179    <groupId>{java_group_id}</groupId>
180    <artifactId>{artifact_id}</artifactId>
181    <version>0.1.0</version>
182
183    <properties>
184        <maven.compiler.source>25</maven.compiler.source>
185        <maven.compiler.target>25</maven.compiler.target>
186        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
187        <junit.version>{junit}</junit.version>
188    </properties>
189
190    <dependencies>
191{dep_block}
192        <dependency>
193            <groupId>com.fasterxml.jackson.core</groupId>
194            <artifactId>jackson-databind</artifactId>
195            <version>{jackson}</version>
196        </dependency>
197        <dependency>
198            <groupId>com.fasterxml.jackson.datatype</groupId>
199            <artifactId>jackson-datatype-jdk8</artifactId>
200            <version>{jackson}</version>
201        </dependency>
202        <dependency>
203            <groupId>org.junit.jupiter</groupId>
204            <artifactId>junit-jupiter</artifactId>
205            <version>${{junit.version}}</version>
206            <scope>test</scope>
207        </dependency>
208    </dependencies>
209
210    <build>
211        <plugins>
212            <plugin>
213                <groupId>org.codehaus.mojo</groupId>
214                <artifactId>build-helper-maven-plugin</artifactId>
215                <version>{build_helper}</version>
216                <executions>
217                    <execution>
218                        <id>add-test-source</id>
219                        <phase>generate-test-sources</phase>
220                        <goals>
221                            <goal>add-test-source</goal>
222                        </goals>
223                        <configuration>
224                            <sources>
225                                <source>src/test/java</source>
226                            </sources>
227                        </configuration>
228                    </execution>
229                </executions>
230            </plugin>
231            <plugin>
232                <groupId>org.apache.maven.plugins</groupId>
233                <artifactId>maven-surefire-plugin</artifactId>
234                <version>{maven_surefire}</version>
235                <configuration>
236                    <argLine>--enable-preview --enable-native-access=ALL-UNNAMED -Djava.library.path=../../target/release</argLine>
237                </configuration>
238            </plugin>
239        </plugins>
240    </build>
241</project>
242"#,
243        junit = tv::maven::JUNIT,
244        jackson = tv::maven::JACKSON_E2E,
245        build_helper = tv::maven::BUILD_HELPER_MAVEN_PLUGIN,
246        maven_surefire = tv::maven::MAVEN_SUREFIRE_PLUGIN_E2E,
247    )
248}
249
250#[allow(clippy::too_many_arguments)]
251fn render_test_file(
252    category: &str,
253    fixtures: &[&Fixture],
254    class_name: &str,
255    function_name: &str,
256    java_group_id: &str,
257    result_var: &str,
258    args: &[crate::config::ArgMapping],
259    options_type: Option<&str>,
260    field_resolver: &FieldResolver,
261    result_is_simple: bool,
262    enum_fields: &HashSet<String>,
263    e2e_config: &E2eConfig,
264) -> String {
265    let mut out = String::new();
266    out.push_str(&hash::header(CommentStyle::DoubleSlash));
267    let test_class_name = format!("{}Test", sanitize_filename(category).to_upper_camel_case());
268
269    // If the class_name is fully qualified (contains '.'), import it and use
270    // only the simple name for method calls.  Otherwise use it as-is.
271    let (import_path, simple_class) = if class_name.contains('.') {
272        let simple = class_name.rsplit('.').next().unwrap_or(class_name);
273        (class_name, simple)
274    } else {
275        ("", class_name)
276    };
277
278    let _ = writeln!(out, "package {java_group_id}.e2e;");
279    let _ = writeln!(out);
280
281    // Check if any fixture (with its resolved call) will emit MAPPER usage.
282    // This covers: non-null json_object with options_type, optional null json_object with
283    // options_type (MAPPER default), and handle args with non-null config.
284    let lang_for_om = "java";
285    let needs_object_mapper_for_options = fixtures.iter().any(|f| {
286        let call_cfg = e2e_config.resolve_call(f.call.as_deref());
287        let eff_opts = call_cfg
288            .overrides
289            .get(lang_for_om)
290            .and_then(|o| o.options_type.as_deref())
291            .or(options_type);
292        if eff_opts.is_none() {
293            return false;
294        }
295        call_cfg.args.iter().any(|arg| {
296            if arg.arg_type != "json_object" {
297                return false;
298            }
299            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
300            let val = f.input.get(field);
301            // Needs MAPPER for: non-null non-array value (MAPPER.readValue) OR
302            // optional null value (MAPPER.readValue("{}", T.class) default).
303            match val {
304                None | Some(serde_json::Value::Null) => arg.optional, // MAPPER default for optional null
305                Some(v) => !v.is_array(),                             // MAPPER.readValue for non-array objects
306            }
307        })
308    });
309    // Also need ObjectMapper when a handle arg has a non-null config.
310    let needs_object_mapper_for_handle = fixtures.iter().any(|f| {
311        args.iter().filter(|a| a.arg_type == "handle").any(|a| {
312            let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
313            !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
314        })
315    });
316    let needs_object_mapper = needs_object_mapper_for_options || needs_object_mapper_for_handle;
317
318    // Collect all options_type values used (class-level + per-fixture call overrides).
319    let mut all_options_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
320    if let Some(t) = options_type {
321        all_options_types.insert(t.to_string());
322    }
323    for f in fixtures.iter() {
324        let call_cfg = e2e_config.resolve_call(f.call.as_deref());
325        if let Some(ov) = call_cfg.overrides.get(lang_for_om) {
326            if let Some(t) = &ov.options_type {
327                all_options_types.insert(t.clone());
328            }
329        }
330    }
331
332    let _ = writeln!(out, "import org.junit.jupiter.api.Test;");
333    let _ = writeln!(out, "import static org.junit.jupiter.api.Assertions.*;");
334    if !import_path.is_empty() {
335        let _ = writeln!(out, "import {import_path};");
336    }
337    if needs_object_mapper {
338        let _ = writeln!(out, "import com.fasterxml.jackson.databind.ObjectMapper;");
339        let _ = writeln!(out, "import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;");
340    }
341    // Import all options types used across fixtures.
342    if needs_object_mapper && !all_options_types.is_empty() {
343        let opts_pkg = if !import_path.is_empty() {
344            import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("")
345        } else {
346            ""
347        };
348        for opts_type in &all_options_types {
349            let qualified = if opts_pkg.is_empty() {
350                opts_type.clone()
351            } else {
352                format!("{opts_pkg}.{opts_type}")
353            };
354            let _ = writeln!(out, "import {qualified};");
355        }
356    }
357    // Import CrawlConfig when handle args need JSON deserialization.
358    if needs_object_mapper_for_handle && !import_path.is_empty() {
359        let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
360        let _ = writeln!(out, "import {pkg}.CrawlConfig;");
361    }
362    let _ = writeln!(out);
363
364    let _ = writeln!(out, "/** E2e tests for category: {category}. */");
365    let _ = writeln!(out, "class {test_class_name} {{");
366
367    if needs_object_mapper {
368        let _ = writeln!(out);
369        let _ = writeln!(
370            out,
371            "    private static final ObjectMapper MAPPER = new ObjectMapper().registerModule(new Jdk8Module());"
372        );
373    }
374
375    for fixture in fixtures {
376        render_test_method(
377            &mut out,
378            fixture,
379            simple_class,
380            function_name,
381            result_var,
382            args,
383            options_type,
384            field_resolver,
385            result_is_simple,
386            enum_fields,
387            e2e_config,
388        );
389        let _ = writeln!(out);
390    }
391
392    let _ = writeln!(out, "}}");
393    out
394}
395
396#[allow(clippy::too_many_arguments)]
397fn render_test_method(
398    out: &mut String,
399    fixture: &Fixture,
400    class_name: &str,
401    _function_name: &str,
402    _result_var: &str,
403    _args: &[crate::config::ArgMapping],
404    options_type: Option<&str>,
405    field_resolver: &FieldResolver,
406    result_is_simple: bool,
407    enum_fields: &HashSet<String>,
408    e2e_config: &E2eConfig,
409) {
410    // Resolve per-fixture call config (supports named calls via fixture.call field).
411    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
412    let lang = "java";
413    let call_overrides = call_config.overrides.get(lang);
414    let effective_function_name = call_overrides
415        .and_then(|o| o.function.as_ref())
416        .cloned()
417        .unwrap_or_else(|| call_config.function.to_lower_camel_case());
418    let effective_result_var = &call_config.result_var;
419    let effective_args = &call_config.args;
420    let function_name = effective_function_name.as_str();
421    let result_var = effective_result_var.as_str();
422    let args: &[crate::config::ArgMapping] = effective_args.as_slice();
423
424    let method_name = fixture.id.to_upper_camel_case();
425    let description = &fixture.description;
426    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
427
428    // The Java binding wraps a C FFI layer and does not expose a handleRequest or equivalent
429    // function that can be called from e2e tests.  Until Java-native HTTP integration tests
430    // are implemented, emit compilable stubs for all fixtures that don't use mock_response.
431    if fixture.mock_response.is_none() {
432        let _ = writeln!(out, "    @Test");
433        let _ = writeln!(out, "    void test{method_name}() {{");
434        let _ = writeln!(out, "        // {description}");
435        let _ = writeln!(
436            out,
437            "        org.junit.jupiter.api.Assumptions.assumeTrue(false, \"TODO: implement Java e2e tests via the spikard Java binding API\");"
438        );
439        let _ = writeln!(out, "    }}");
440        return;
441    }
442
443    // Resolve per-fixture options_type: prefer the java call override, fall back to class-level.
444    let effective_options_type: Option<String> = call_overrides
445        .and_then(|o| o.options_type.clone())
446        .or_else(|| options_type.map(|s| s.to_string()));
447    let effective_options_type = effective_options_type.as_deref();
448
449    // Resolve per-fixture result_is_simple and result_is_bytes from the call override.
450    let effective_result_is_simple = call_overrides.is_some_and(|o| o.result_is_simple) || result_is_simple;
451    let effective_result_is_bytes = call_overrides.is_some_and(|o| o.result_is_bytes);
452
453    // Check if this test needs ObjectMapper deserialization for json_object args.
454    // Strip "input." prefix when looking up field in fixture.input.
455    let needs_deser = effective_options_type.is_some()
456        && args.iter().any(|arg| {
457            if arg.arg_type != "json_object" {
458                return false;
459            }
460            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
461            fixture.input.get(field).is_some_and(|v| !v.is_null() && !v.is_array())
462        });
463
464    // Always add throws Exception since the convert method may throw checked exceptions.
465    let throws_clause = " throws Exception";
466
467    let _ = writeln!(out, "    @Test");
468    let _ = writeln!(out, "    void test{method_name}(){throws_clause} {{");
469    let _ = writeln!(out, "        // {description}");
470
471    // Emit ObjectMapper deserialization bindings for json_object args.
472    if let (true, Some(opts_type)) = (needs_deser, effective_options_type) {
473        for arg in args {
474            if arg.arg_type == "json_object" {
475                let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
476                if let Some(val) = fixture.input.get(field) {
477                    if !val.is_null() && !val.is_array() {
478                        // Fixture keys are camelCase; the Java record uses
479                        // @JsonProperty("snake_case") annotations. Normalize keys so Jackson
480                        // can deserialize them correctly.
481                        let normalized = super::normalize_json_keys_to_snake_case(val);
482                        let json_str = serde_json::to_string(&normalized).unwrap_or_default();
483                        let var_name = &arg.name;
484                        let _ = writeln!(
485                            out,
486                            "        var {var_name} = MAPPER.readValue(\"{}\", {opts_type}.class);",
487                            escape_java(&json_str)
488                        );
489                    }
490                }
491            }
492        }
493    }
494
495    let (mut setup_lines, args_str) =
496        build_args_and_setup(&fixture.input, args, class_name, effective_options_type, &fixture.id);
497
498    // Build visitor if present and add to setup
499    let mut visitor_arg = String::new();
500    if let Some(visitor_spec) = &fixture.visitor {
501        visitor_arg = build_java_visitor(&mut setup_lines, visitor_spec, class_name);
502    }
503
504    for line in &setup_lines {
505        let _ = writeln!(out, "        {line}");
506    }
507
508    let final_args = if visitor_arg.is_empty() {
509        args_str
510    } else {
511        format!("{args_str}, {visitor_arg}")
512    };
513
514    if expects_error {
515        let _ = writeln!(
516            out,
517            "        assertThrows(Exception.class, () -> {class_name}.{function_name}({final_args}));"
518        );
519        let _ = writeln!(out, "    }}");
520        return;
521    }
522
523    let _ = writeln!(
524        out,
525        "        var {result_var} = {class_name}.{function_name}({final_args});"
526    );
527
528    // Emit a `source` variable for run_query assertions that need the raw bytes.
529    let needs_source_var = fixture
530        .assertions
531        .iter()
532        .any(|a| a.assertion_type == "method_result" && a.method.as_deref() == Some("run_query"));
533    if needs_source_var {
534        // Find the source_code arg to emit a `source` binding.
535        if let Some(source_arg) = args.iter().find(|a| a.field == "source_code") {
536            let field = source_arg.field.strip_prefix("input.").unwrap_or(&source_arg.field);
537            if let Some(val) = fixture.input.get(field) {
538                let java_val = json_to_java(val);
539                let _ = writeln!(out, "        var source = {java_val}.getBytes();");
540            }
541        }
542    }
543
544    for assertion in &fixture.assertions {
545        render_assertion(
546            out,
547            assertion,
548            result_var,
549            class_name,
550            field_resolver,
551            effective_result_is_simple,
552            effective_result_is_bytes,
553            enum_fields,
554        );
555    }
556
557    let _ = writeln!(out, "    }}");
558}
559
560/// Build setup lines (e.g. handle creation) and the argument list for the function call.
561///
562/// Returns `(setup_lines, args_string)`.
563fn build_args_and_setup(
564    input: &serde_json::Value,
565    args: &[crate::config::ArgMapping],
566    class_name: &str,
567    options_type: Option<&str>,
568    fixture_id: &str,
569) -> (Vec<String>, String) {
570    if args.is_empty() {
571        return (Vec::new(), String::new());
572    }
573
574    let mut setup_lines: Vec<String> = Vec::new();
575    let mut parts: Vec<String> = Vec::new();
576
577    for arg in args {
578        if arg.arg_type == "mock_url" {
579            setup_lines.push(format!(
580                "String {} = System.getenv(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
581                arg.name,
582            ));
583            parts.push(arg.name.clone());
584            continue;
585        }
586
587        if arg.arg_type == "handle" {
588            // Generate a createEngine (or equivalent) call and pass the variable.
589            let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
590            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
591            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
592            if config_value.is_null()
593                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
594            {
595                setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
596            } else {
597                let json_str = serde_json::to_string(config_value).unwrap_or_default();
598                let name = &arg.name;
599                setup_lines.push(format!(
600                    "var {name}Config = MAPPER.readValue(\"{}\", CrawlConfig.class);",
601                    escape_java(&json_str),
602                ));
603                setup_lines.push(format!(
604                    "var {} = {class_name}.{constructor_name}({name}Config);",
605                    arg.name,
606                    name = name,
607                ));
608            }
609            parts.push(arg.name.clone());
610            continue;
611        }
612
613        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
614        let val = input.get(field);
615        match val {
616            None | Some(serde_json::Value::Null) if arg.optional => {
617                // Optional arg with no fixture value: emit positional null/default so the call
618                // has the right arity. For json_object optional args, deserialise an empty object
619                // so we get the right type rather than a raw null.
620                if arg.arg_type == "json_object" {
621                    if let Some(opts_type) = options_type {
622                        parts.push(format!("MAPPER.readValue(\"{{}}\", {opts_type}.class)"));
623                    } else {
624                        parts.push("null".to_string());
625                    }
626                } else {
627                    parts.push("null".to_string());
628                }
629            }
630            None | Some(serde_json::Value::Null) => {
631                // Required arg with no fixture value: pass a language-appropriate default.
632                let default_val = match arg.arg_type.as_str() {
633                    "string" | "file_path" => "\"\"".to_string(),
634                    "int" | "integer" => "0".to_string(),
635                    "float" | "number" => "0.0d".to_string(),
636                    "bool" | "boolean" => "false".to_string(),
637                    _ => "null".to_string(),
638                };
639                parts.push(default_val);
640            }
641            Some(v) => {
642                if arg.arg_type == "json_object" {
643                    // Array json_object args: emit inline Java list expression.
644                    // Use element_type to emit the correct numeric literal suffix (f vs d).
645                    if v.is_array() {
646                        let elem_type = arg.element_type.as_deref();
647                        parts.push(json_to_java_typed(v, elem_type));
648                        continue;
649                    }
650                    // Object json_object args with options_type: use pre-deserialized variable.
651                    if options_type.is_some() {
652                        parts.push(arg.name.clone());
653                        continue;
654                    }
655                    parts.push(json_to_java(v));
656                    continue;
657                }
658                // bytes args must be passed as byte[], not String.
659                if arg.arg_type == "bytes" {
660                    let val = json_to_java(v);
661                    parts.push(format!("{val}.getBytes()"));
662                    continue;
663                }
664                // file_path args must be wrapped in java.nio.file.Path.of().
665                if arg.arg_type == "file_path" {
666                    let val = json_to_java(v);
667                    parts.push(format!("java.nio.file.Path.of({val})"));
668                    continue;
669                }
670                parts.push(json_to_java(v));
671            }
672        }
673    }
674
675    (setup_lines, parts.join(", "))
676}
677
678#[allow(clippy::too_many_arguments)]
679fn render_assertion(
680    out: &mut String,
681    assertion: &Assertion,
682    result_var: &str,
683    class_name: &str,
684    field_resolver: &FieldResolver,
685    result_is_simple: bool,
686    result_is_bytes: bool,
687    enum_fields: &HashSet<String>,
688) {
689    // Handle synthetic/virtual fields that are computed rather than direct record accessors.
690    if let Some(f) = &assertion.field {
691        match f.as_str() {
692            // ---- ExtractionResult chunk-level computed predicates ----
693            "chunks_have_content" => {
694                let pred = format!(
695                    "{result_var}.chunks().orElse(java.util.List.of()).stream().allMatch(c -> c.content() != null && !c.content().isBlank())"
696                );
697                match assertion.assertion_type.as_str() {
698                    "is_true" => {
699                        let _ = writeln!(out, "        assertTrue({pred}, \"expected true\");");
700                    }
701                    "is_false" => {
702                        let _ = writeln!(out, "        assertFalse({pred}, \"expected false\");");
703                    }
704                    _ => {
705                        let _ = writeln!(
706                            out,
707                            "        // skipped: unsupported assertion on synthetic field '{f}'"
708                        );
709                    }
710                }
711                return;
712            }
713            "chunks_have_heading_context" => {
714                let pred = format!(
715                    "{result_var}.chunks().orElse(java.util.List.of()).stream().allMatch(c -> c.metadata().headingContext().isPresent())"
716                );
717                match assertion.assertion_type.as_str() {
718                    "is_true" => {
719                        let _ = writeln!(out, "        assertTrue({pred}, \"expected true\");");
720                    }
721                    "is_false" => {
722                        let _ = writeln!(out, "        assertFalse({pred}, \"expected false\");");
723                    }
724                    _ => {
725                        let _ = writeln!(
726                            out,
727                            "        // skipped: unsupported assertion on synthetic field '{f}'"
728                        );
729                    }
730                }
731                return;
732            }
733            "chunks_have_embeddings" => {
734                let pred = format!(
735                    "{result_var}.chunks().orElse(java.util.List.of()).stream().allMatch(c -> c.embedding() != null && !c.embedding().isEmpty())"
736                );
737                match assertion.assertion_type.as_str() {
738                    "is_true" => {
739                        let _ = writeln!(out, "        assertTrue({pred}, \"expected true\");");
740                    }
741                    "is_false" => {
742                        let _ = writeln!(out, "        assertFalse({pred}, \"expected false\");");
743                    }
744                    _ => {
745                        let _ = writeln!(
746                            out,
747                            "        // skipped: unsupported assertion on synthetic field '{f}'"
748                        );
749                    }
750                }
751                return;
752            }
753            "first_chunk_starts_with_heading" => {
754                let pred = format!(
755                    "{result_var}.chunks().orElse(java.util.List.of()).stream().findFirst().map(c -> c.metadata().headingContext().isPresent()).orElse(false)"
756                );
757                match assertion.assertion_type.as_str() {
758                    "is_true" => {
759                        let _ = writeln!(out, "        assertTrue({pred}, \"expected true\");");
760                    }
761                    "is_false" => {
762                        let _ = writeln!(out, "        assertFalse({pred}, \"expected false\");");
763                    }
764                    _ => {
765                        let _ = writeln!(
766                            out,
767                            "        // skipped: unsupported assertion on synthetic field '{f}'"
768                        );
769                    }
770                }
771                return;
772            }
773            // ---- EmbedResponse virtual fields ----
774            // When result_is_simple=true the result IS List<List<Float>> (the raw embeddings list).
775            // When result_is_simple=false the result has an .embeddings() accessor.
776            "embedding_dimensions" => {
777                // Dimension = size of the first embedding vector in the list.
778                let embed_list = if result_is_simple {
779                    result_var.to_string()
780                } else {
781                    format!("{result_var}.embeddings()")
782                };
783                let expr = format!("({embed_list}.isEmpty() ? 0 : {embed_list}.get(0).size())");
784                match assertion.assertion_type.as_str() {
785                    "equals" => {
786                        if let Some(val) = &assertion.value {
787                            let java_val = json_to_java(val);
788                            let _ = writeln!(out, "        assertEquals({java_val}, {expr});");
789                        }
790                    }
791                    "greater_than" => {
792                        if let Some(val) = &assertion.value {
793                            let java_val = json_to_java(val);
794                            let _ = writeln!(
795                                out,
796                                "        assertTrue({expr} > {java_val}, \"expected > {java_val}\");"
797                            );
798                        }
799                    }
800                    _ => {
801                        let _ = writeln!(out, "        // skipped: unsupported assertion on '{f}'");
802                    }
803                }
804                return;
805            }
806            "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
807                // These are validation predicates that require iterating the embedding matrix.
808                let embed_list = if result_is_simple {
809                    result_var.to_string()
810                } else {
811                    format!("{result_var}.embeddings()")
812                };
813                let pred = match f.as_str() {
814                    "embeddings_valid" => {
815                        format!("{embed_list}.stream().allMatch(e -> e != null && !e.isEmpty())")
816                    }
817                    "embeddings_finite" => {
818                        format!("{embed_list}.stream().flatMap(java.util.Collection::stream).allMatch(Float::isFinite)")
819                    }
820                    "embeddings_non_zero" => {
821                        format!("{embed_list}.stream().allMatch(e -> e.stream().anyMatch(v -> v != 0.0f))")
822                    }
823                    "embeddings_normalized" => format!(
824                        "{embed_list}.stream().allMatch(e -> {{ double n = e.stream().mapToDouble(v -> v * v).sum(); return Math.abs(n - 1.0) < 1e-3; }})"
825                    ),
826                    _ => unreachable!(),
827                };
828                match assertion.assertion_type.as_str() {
829                    "is_true" => {
830                        let _ = writeln!(out, "        assertTrue({pred}, \"expected true\");");
831                    }
832                    "is_false" => {
833                        let _ = writeln!(out, "        assertFalse({pred}, \"expected false\");");
834                    }
835                    _ => {
836                        let _ = writeln!(out, "        // skipped: unsupported assertion on '{f}'");
837                    }
838                }
839                return;
840            }
841            // ---- Fields not present on the Java ExtractionResult ----
842            "keywords" | "keywords_count" => {
843                let _ = writeln!(
844                    out,
845                    "        // skipped: field '{f}' not available on Java ExtractionResult"
846                );
847                return;
848            }
849            // ---- metadata not_empty / is_empty: Metadata is a required record, not Optional ----
850            // Metadata has no .isEmpty() method; check that at least one optional field is present.
851            "metadata" => {
852                match assertion.assertion_type.as_str() {
853                    "not_empty" => {
854                        let _ = writeln!(
855                            out,
856                            "        assertTrue({result_var}.metadata().title().isPresent() || {result_var}.metadata().subject().isPresent() || !{result_var}.metadata().additional().isEmpty(), \"expected non-empty value\");"
857                        );
858                        return;
859                    }
860                    "is_empty" => {
861                        let _ = writeln!(
862                            out,
863                            "        assertFalse({result_var}.metadata().title().isPresent() || {result_var}.metadata().subject().isPresent() || !{result_var}.metadata().additional().isEmpty(), \"expected empty value\");"
864                        );
865                        return;
866                    }
867                    _ => {} // fall through to normal handling
868                }
869            }
870            _ => {}
871        }
872    }
873
874    // Skip assertions on fields that don't exist on the result type.
875    if let Some(f) = &assertion.field {
876        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
877            let _ = writeln!(out, "        // skipped: field '{f}' not available on result type");
878            return;
879        }
880    }
881
882    // Determine if this field is an enum type (no `.contains()` on enums in Java).
883    // Check both the raw fixture field path and the resolved (aliased) path so that
884    // `fields_enum` entries can use either form (e.g., `"assets[].category"` or the
885    // resolved `"assets[].asset_category"`).
886    let field_is_enum = assertion
887        .field
888        .as_deref()
889        .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
890
891    let field_expr = if result_is_simple {
892        result_var.to_string()
893    } else {
894        match &assertion.field {
895            Some(f) if !f.is_empty() => {
896                let accessor = field_resolver.accessor(f, "java", result_var);
897                let resolved = field_resolver.resolve(f);
898                // Unwrap Optional fields with a type-appropriate fallback.
899                // Map.get() returns nullable, not Optional, so skip .orElse() for map access.
900                if field_resolver.is_optional(resolved) && !field_resolver.has_map_access(f) {
901                    // Choose the right orElse fallback based on the assertion type and field type.
902                    match assertion.assertion_type.as_str() {
903                        // For not_empty / is_empty on Optional fields, return the raw Optional
904                        // so the assertion arms can call isPresent()/isEmpty().
905                        "not_empty" | "is_empty" => accessor,
906                        // For size/count assertions on Optional<List<T>> fields, use List.of() fallback.
907                        "count_min" | "count_equals" => {
908                            format!("{accessor}.orElse(java.util.List.of())")
909                        }
910                        // For numeric comparisons on Optional<Long/Integer> fields, use 0L.
911                        "greater_than" | "less_than" | "greater_than_or_equal" | "less_than_or_equal" => {
912                            if field_resolver.is_array(resolved) {
913                                format!("{accessor}.orElse(java.util.List.of())")
914                            } else {
915                                format!("{accessor}.orElse(0L)")
916                            }
917                        }
918                        _ if field_resolver.is_array(resolved) => {
919                            format!("{accessor}.orElse(java.util.List.of())")
920                        }
921                        _ => format!("{accessor}.orElse(\"\")"),
922                    }
923                } else {
924                    accessor
925                }
926            }
927            _ => result_var.to_string(),
928        }
929    };
930
931    // For enum fields, string-based assertions need .getValue() to convert the enum to
932    // its serde-serialized lowercase string value (e.g., AssetCategory.Image -> "image").
933    // All alef-generated Java enums expose a getValue() method annotated with @JsonValue.
934    let string_expr = if field_is_enum {
935        format!("{field_expr}.getValue()")
936    } else {
937        field_expr.clone()
938    };
939
940    match assertion.assertion_type.as_str() {
941        "equals" => {
942            if let Some(expected) = &assertion.value {
943                let java_val = json_to_java(expected);
944                if expected.is_string() {
945                    let _ = writeln!(out, "        assertEquals({java_val}, {string_expr}.trim());");
946                } else {
947                    let _ = writeln!(out, "        assertEquals({java_val}, {field_expr});");
948                }
949            }
950        }
951        "contains" => {
952            if let Some(expected) = &assertion.value {
953                let java_val = json_to_java(expected);
954                let _ = writeln!(
955                    out,
956                    "        assertTrue({string_expr}.contains({java_val}), \"expected to contain: \" + {java_val});"
957                );
958            }
959        }
960        "contains_all" => {
961            if let Some(values) = &assertion.values {
962                for val in values {
963                    let java_val = json_to_java(val);
964                    let _ = writeln!(
965                        out,
966                        "        assertTrue({string_expr}.contains({java_val}), \"expected to contain: \" + {java_val});"
967                    );
968                }
969            }
970        }
971        "not_contains" => {
972            if let Some(expected) = &assertion.value {
973                let java_val = json_to_java(expected);
974                let _ = writeln!(
975                    out,
976                    "        assertFalse({string_expr}.contains({java_val}), \"expected NOT to contain: \" + {java_val});"
977                );
978            }
979        }
980        "not_empty" => {
981            let _ = writeln!(
982                out,
983                "        assertFalse({field_expr}.isEmpty(), \"expected non-empty value\");"
984            );
985        }
986        "is_empty" => {
987            let _ = writeln!(
988                out,
989                "        assertTrue({field_expr}.isEmpty(), \"expected empty value\");"
990            );
991        }
992        "contains_any" => {
993            if let Some(values) = &assertion.values {
994                let checks: Vec<String> = values
995                    .iter()
996                    .map(|v| {
997                        let java_val = json_to_java(v);
998                        format!("{string_expr}.contains({java_val})")
999                    })
1000                    .collect();
1001                let joined = checks.join(" || ");
1002                let _ = writeln!(
1003                    out,
1004                    "        assertTrue({joined}, \"expected to contain at least one of the specified values\");"
1005                );
1006            }
1007        }
1008        "greater_than" => {
1009            if let Some(val) = &assertion.value {
1010                let java_val = json_to_java(val);
1011                let _ = writeln!(
1012                    out,
1013                    "        assertTrue({field_expr} > {java_val}, \"expected > {java_val}\");"
1014                );
1015            }
1016        }
1017        "less_than" => {
1018            if let Some(val) = &assertion.value {
1019                let java_val = json_to_java(val);
1020                let _ = writeln!(
1021                    out,
1022                    "        assertTrue({field_expr} < {java_val}, \"expected < {java_val}\");"
1023                );
1024            }
1025        }
1026        "greater_than_or_equal" => {
1027            if let Some(val) = &assertion.value {
1028                let java_val = json_to_java(val);
1029                let _ = writeln!(
1030                    out,
1031                    "        assertTrue({field_expr} >= {java_val}, \"expected >= {java_val}\");"
1032                );
1033            }
1034        }
1035        "less_than_or_equal" => {
1036            if let Some(val) = &assertion.value {
1037                let java_val = json_to_java(val);
1038                let _ = writeln!(
1039                    out,
1040                    "        assertTrue({field_expr} <= {java_val}, \"expected <= {java_val}\");"
1041                );
1042            }
1043        }
1044        "starts_with" => {
1045            if let Some(expected) = &assertion.value {
1046                let java_val = json_to_java(expected);
1047                let _ = writeln!(
1048                    out,
1049                    "        assertTrue({string_expr}.startsWith({java_val}), \"expected to start with: \" + {java_val});"
1050                );
1051            }
1052        }
1053        "ends_with" => {
1054            if let Some(expected) = &assertion.value {
1055                let java_val = json_to_java(expected);
1056                let _ = writeln!(
1057                    out,
1058                    "        assertTrue({string_expr}.endsWith({java_val}), \"expected to end with: \" + {java_val});"
1059                );
1060            }
1061        }
1062        "min_length" => {
1063            if let Some(val) = &assertion.value {
1064                if let Some(n) = val.as_u64() {
1065                    // byte[] uses `.length` (array field), String uses `.length()` (method).
1066                    let len_expr = if result_is_bytes {
1067                        format!("{field_expr}.length")
1068                    } else {
1069                        format!("{field_expr}.length()")
1070                    };
1071                    let _ = writeln!(
1072                        out,
1073                        "        assertTrue({len_expr} >= {n}, \"expected length >= {n}\");"
1074                    );
1075                }
1076            }
1077        }
1078        "max_length" => {
1079            if let Some(val) = &assertion.value {
1080                if let Some(n) = val.as_u64() {
1081                    let len_expr = if result_is_bytes {
1082                        format!("{field_expr}.length")
1083                    } else {
1084                        format!("{field_expr}.length()")
1085                    };
1086                    let _ = writeln!(
1087                        out,
1088                        "        assertTrue({len_expr} <= {n}, \"expected length <= {n}\");"
1089                    );
1090                }
1091            }
1092        }
1093        "count_min" => {
1094            if let Some(val) = &assertion.value {
1095                if let Some(n) = val.as_u64() {
1096                    let _ = writeln!(
1097                        out,
1098                        "        assertTrue({field_expr}.size() >= {n}, \"expected at least {n} elements\");"
1099                    );
1100                }
1101            }
1102        }
1103        "count_equals" => {
1104            if let Some(val) = &assertion.value {
1105                if let Some(n) = val.as_u64() {
1106                    let _ = writeln!(
1107                        out,
1108                        "        assertEquals({n}, {field_expr}.size(), \"expected exactly {n} elements\");"
1109                    );
1110                }
1111            }
1112        }
1113        "is_true" => {
1114            let _ = writeln!(out, "        assertTrue({field_expr}, \"expected true\");");
1115        }
1116        "is_false" => {
1117            let _ = writeln!(out, "        assertFalse({field_expr}, \"expected false\");");
1118        }
1119        "method_result" => {
1120            if let Some(method_name) = &assertion.method {
1121                let call_expr = build_java_method_call(result_var, method_name, assertion.args.as_ref(), class_name);
1122                let check = assertion.check.as_deref().unwrap_or("is_true");
1123                // Methods that return a collection (List) rather than a scalar.
1124                let method_returns_collection =
1125                    matches!(method_name.as_str(), "find_nodes_by_type" | "findNodesByType");
1126                match check {
1127                    "equals" => {
1128                        if let Some(val) = &assertion.value {
1129                            if val.is_boolean() {
1130                                if val.as_bool() == Some(true) {
1131                                    let _ = writeln!(out, "        assertTrue({call_expr});");
1132                                } else {
1133                                    let _ = writeln!(out, "        assertFalse({call_expr});");
1134                                }
1135                            } else if method_returns_collection {
1136                                let java_val = json_to_java(val);
1137                                let _ = writeln!(out, "        assertEquals({java_val}, {call_expr}.size());");
1138                            } else {
1139                                let java_val = json_to_java(val);
1140                                let _ = writeln!(out, "        assertEquals({java_val}, {call_expr});");
1141                            }
1142                        }
1143                    }
1144                    "is_true" => {
1145                        let _ = writeln!(out, "        assertTrue({call_expr});");
1146                    }
1147                    "is_false" => {
1148                        let _ = writeln!(out, "        assertFalse({call_expr});");
1149                    }
1150                    "greater_than_or_equal" => {
1151                        if let Some(val) = &assertion.value {
1152                            let n = val.as_u64().unwrap_or(0);
1153                            let _ = writeln!(out, "        assertTrue({call_expr} >= {n}, \"expected >= {n}\");");
1154                        }
1155                    }
1156                    "count_min" => {
1157                        if let Some(val) = &assertion.value {
1158                            let n = val.as_u64().unwrap_or(0);
1159                            let _ = writeln!(
1160                                out,
1161                                "        assertTrue({call_expr}.size() >= {n}, \"expected at least {n} elements\");"
1162                            );
1163                        }
1164                    }
1165                    "is_error" => {
1166                        let _ = writeln!(out, "        assertThrows(Exception.class, () -> {{ {call_expr}; }});");
1167                    }
1168                    "contains" => {
1169                        if let Some(val) = &assertion.value {
1170                            let java_val = json_to_java(val);
1171                            let _ = writeln!(
1172                                out,
1173                                "        assertTrue({call_expr}.contains({java_val}), \"expected to contain: \" + {java_val});"
1174                            );
1175                        }
1176                    }
1177                    other_check => {
1178                        panic!("Java e2e generator: unsupported method_result check type: {other_check}");
1179                    }
1180                }
1181            } else {
1182                panic!("Java e2e generator: method_result assertion missing 'method' field");
1183            }
1184        }
1185        "matches_regex" => {
1186            if let Some(expected) = &assertion.value {
1187                let java_val = json_to_java(expected);
1188                let _ = writeln!(
1189                    out,
1190                    "        assertTrue({string_expr}.matches({java_val}), \"expected value to match regex: \" + {java_val});"
1191                );
1192            }
1193        }
1194        "not_error" => {
1195            // Already handled by the call succeeding without exception.
1196        }
1197        "error" => {
1198            // Handled at the test method level.
1199        }
1200        other => {
1201            panic!("Java e2e generator: unsupported assertion type: {other}");
1202        }
1203    }
1204}
1205
1206/// Build a Java call expression for a `method_result` assertion on a tree-sitter Tree.
1207///
1208/// Maps method names to the appropriate Java static/instance method calls.
1209fn build_java_method_call(
1210    result_var: &str,
1211    method_name: &str,
1212    args: Option<&serde_json::Value>,
1213    class_name: &str,
1214) -> String {
1215    match method_name {
1216        "root_child_count" => format!("{result_var}.rootNode().childCount()"),
1217        "root_node_type" => format!("{result_var}.rootNode().kind()"),
1218        "named_children_count" => format!("{result_var}.rootNode().namedChildCount()"),
1219        "has_error_nodes" => format!("{class_name}.treeHasErrorNodes({result_var})"),
1220        "error_count" | "tree_error_count" => format!("{class_name}.treeErrorCount({result_var})"),
1221        "tree_to_sexp" => format!("{class_name}.treeToSexp({result_var})"),
1222        "contains_node_type" => {
1223            let node_type = args
1224                .and_then(|a| a.get("node_type"))
1225                .and_then(|v| v.as_str())
1226                .unwrap_or("");
1227            format!("{class_name}.treeContainsNodeType({result_var}, \"{node_type}\")")
1228        }
1229        "find_nodes_by_type" => {
1230            let node_type = args
1231                .and_then(|a| a.get("node_type"))
1232                .and_then(|v| v.as_str())
1233                .unwrap_or("");
1234            format!("{class_name}.findNodesByType({result_var}, \"{node_type}\")")
1235        }
1236        "run_query" => {
1237            let query_source = args
1238                .and_then(|a| a.get("query_source"))
1239                .and_then(|v| v.as_str())
1240                .unwrap_or("");
1241            let language = args
1242                .and_then(|a| a.get("language"))
1243                .and_then(|v| v.as_str())
1244                .unwrap_or("");
1245            let escaped_query = escape_java(query_source);
1246            format!("{class_name}.runQuery({result_var}, \"{language}\", \"{escaped_query}\", source)")
1247        }
1248        _ => {
1249            format!("{result_var}.{}()", method_name.to_lower_camel_case())
1250        }
1251    }
1252}
1253
1254/// Convert a `serde_json::Value` to a Java literal string.
1255fn json_to_java(value: &serde_json::Value) -> String {
1256    json_to_java_typed(value, None)
1257}
1258
1259/// Convert a JSON value to a Java literal, optionally overriding number type for array elements.
1260/// `element_type` controls how numeric array elements are emitted: "f32" → `1.0f`, otherwise `1.0d`.
1261fn json_to_java_typed(value: &serde_json::Value, element_type: Option<&str>) -> String {
1262    match value {
1263        serde_json::Value::String(s) => format!("\"{}\"", escape_java(s)),
1264        serde_json::Value::Bool(b) => b.to_string(),
1265        serde_json::Value::Number(n) => {
1266            if n.is_f64() {
1267                match element_type {
1268                    Some("f32" | "float" | "Float") => format!("{}f", n),
1269                    _ => format!("{}d", n),
1270                }
1271            } else {
1272                n.to_string()
1273            }
1274        }
1275        serde_json::Value::Null => "null".to_string(),
1276        serde_json::Value::Array(arr) => {
1277            let items: Vec<String> = arr.iter().map(|v| json_to_java_typed(v, element_type)).collect();
1278            format!("java.util.List.of({})", items.join(", "))
1279        }
1280        serde_json::Value::Object(_) => {
1281            let json_str = serde_json::to_string(value).unwrap_or_default();
1282            format!("\"{}\"", escape_java(&json_str))
1283        }
1284    }
1285}
1286
1287// ---------------------------------------------------------------------------
1288// Visitor generation
1289// ---------------------------------------------------------------------------
1290
1291/// Build a Java visitor class and add setup lines. Returns the visitor variable name.
1292fn build_java_visitor(
1293    setup_lines: &mut Vec<String>,
1294    visitor_spec: &crate::fixture::VisitorSpec,
1295    class_name: &str,
1296) -> String {
1297    setup_lines.push("class _TestVisitor implements TestVisitor {".to_string());
1298    for (method_name, action) in &visitor_spec.callbacks {
1299        emit_java_visitor_method(setup_lines, method_name, action, class_name);
1300    }
1301    setup_lines.push("}".to_string());
1302    setup_lines.push("var visitor = new _TestVisitor();".to_string());
1303    "visitor".to_string()
1304}
1305
1306/// Emit a Java visitor method for a callback action.
1307fn emit_java_visitor_method(
1308    setup_lines: &mut Vec<String>,
1309    method_name: &str,
1310    action: &CallbackAction,
1311    _class_name: &str,
1312) {
1313    let camel_method = method_to_camel(method_name);
1314    let params = match method_name {
1315        "visit_link" => "VisitContext ctx, String href, String text, String title",
1316        "visit_image" => "VisitContext ctx, String src, String alt, String title",
1317        "visit_heading" => "VisitContext ctx, int level, String text, String id",
1318        "visit_code_block" => "VisitContext ctx, String lang, String code",
1319        "visit_code_inline"
1320        | "visit_strong"
1321        | "visit_emphasis"
1322        | "visit_strikethrough"
1323        | "visit_underline"
1324        | "visit_subscript"
1325        | "visit_superscript"
1326        | "visit_mark"
1327        | "visit_button"
1328        | "visit_summary"
1329        | "visit_figcaption"
1330        | "visit_definition_term"
1331        | "visit_definition_description" => "VisitContext ctx, String text",
1332        "visit_text" => "VisitContext ctx, String text",
1333        "visit_list_item" => "VisitContext ctx, boolean ordered, String marker, String text",
1334        "visit_blockquote" => "VisitContext ctx, String content, int depth",
1335        "visit_table_row" => "VisitContext ctx, java.util.List<String> cells, boolean isHeader",
1336        "visit_custom_element" => "VisitContext ctx, String tagName, String html",
1337        "visit_form" => "VisitContext ctx, String actionUrl, String method",
1338        "visit_input" => "VisitContext ctx, String inputType, String name, String value",
1339        "visit_audio" | "visit_video" | "visit_iframe" => "VisitContext ctx, String src",
1340        "visit_details" => "VisitContext ctx, boolean isOpen",
1341        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
1342            "VisitContext ctx, String output"
1343        }
1344        "visit_list_start" => "VisitContext ctx, boolean ordered",
1345        "visit_list_end" => "VisitContext ctx, boolean ordered, String output",
1346        _ => "VisitContext ctx",
1347    };
1348
1349    setup_lines.push(format!("    @Override public VisitResult {camel_method}({params}) {{"));
1350    match action {
1351        CallbackAction::Skip => {
1352            setup_lines.push("        return VisitResult.skip();".to_string());
1353        }
1354        CallbackAction::Continue => {
1355            setup_lines.push("        return VisitResult.continue_();".to_string());
1356        }
1357        CallbackAction::PreserveHtml => {
1358            setup_lines.push("        return VisitResult.preserveHtml();".to_string());
1359        }
1360        CallbackAction::Custom { output } => {
1361            let escaped = escape_java(output);
1362            setup_lines.push(format!("        return VisitResult.custom(\"{escaped}\");"));
1363        }
1364        CallbackAction::CustomTemplate { template } => {
1365            let escaped = escape_java(template);
1366            setup_lines.push(format!(
1367                "        return VisitResult.custom(String.format(\"{escaped}\"));"
1368            ));
1369        }
1370    }
1371    setup_lines.push("    }".to_string());
1372}
1373
1374/// Convert snake_case method names to Java camelCase.
1375fn method_to_camel(snake: &str) -> String {
1376    snake.to_lower_camel_case()
1377}