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.
75        let test_base = output_base
76            .join("src")
77            .join("test")
78            .join("java")
79            .join("dev")
80            .join("kreuzberg")
81            .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                result_var,
110                &e2e_config.call.args,
111                options_type.as_deref(),
112                &field_resolver,
113                result_is_simple,
114                &e2e_config.fields_enum,
115                e2e_config,
116            );
117            files.push(GeneratedFile {
118                path: test_base.join(class_file_name),
119                content,
120                generated_header: true,
121            });
122        }
123
124        Ok(files)
125    }
126
127    fn language_name(&self) -> &'static str {
128        "java"
129    }
130}
131
132// ---------------------------------------------------------------------------
133// Rendering
134// ---------------------------------------------------------------------------
135
136fn render_pom_xml(
137    pkg_name: &str,
138    java_group_id: &str,
139    pkg_version: &str,
140    dep_mode: crate::config::DependencyMode,
141) -> String {
142    // pkg_name may be in "groupId:artifactId" Maven format; split accordingly.
143    let (dep_group_id, dep_artifact_id) = if let Some((g, a)) = pkg_name.split_once(':') {
144        (g, a)
145    } else {
146        (java_group_id, pkg_name)
147    };
148    let artifact_id = format!("{dep_artifact_id}-e2e-java");
149    let dep_block = match dep_mode {
150        crate::config::DependencyMode::Registry => {
151            format!(
152                r#"        <dependency>
153            <groupId>{dep_group_id}</groupId>
154            <artifactId>{dep_artifact_id}</artifactId>
155            <version>{pkg_version}</version>
156        </dependency>"#
157            )
158        }
159        crate::config::DependencyMode::Local => {
160            format!(
161                r#"        <dependency>
162            <groupId>{dep_group_id}</groupId>
163            <artifactId>{dep_artifact_id}</artifactId>
164            <version>{pkg_version}</version>
165            <scope>system</scope>
166            <systemPath>${{project.basedir}}/../../packages/java/target/{dep_artifact_id}-{pkg_version}.jar</systemPath>
167        </dependency>"#
168            )
169        }
170    };
171    format!(
172        r#"<?xml version="1.0" encoding="UTF-8"?>
173<project xmlns="http://maven.apache.org/POM/4.0.0"
174         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
175         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
176    <modelVersion>4.0.0</modelVersion>
177
178    <groupId>dev.kreuzberg</groupId>
179    <artifactId>{artifact_id}</artifactId>
180    <version>0.1.0</version>
181
182    <properties>
183        <maven.compiler.source>25</maven.compiler.source>
184        <maven.compiler.target>25</maven.compiler.target>
185        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
186        <junit.version>{junit}</junit.version>
187    </properties>
188
189    <dependencies>
190{dep_block}
191        <dependency>
192            <groupId>com.fasterxml.jackson.core</groupId>
193            <artifactId>jackson-databind</artifactId>
194            <version>{jackson}</version>
195        </dependency>
196        <dependency>
197            <groupId>com.fasterxml.jackson.datatype</groupId>
198            <artifactId>jackson-datatype-jdk8</artifactId>
199            <version>{jackson}</version>
200        </dependency>
201        <dependency>
202            <groupId>org.junit.jupiter</groupId>
203            <artifactId>junit-jupiter</artifactId>
204            <version>${{junit.version}}</version>
205            <scope>test</scope>
206        </dependency>
207    </dependencies>
208
209    <build>
210        <plugins>
211            <plugin>
212                <groupId>org.codehaus.mojo</groupId>
213                <artifactId>build-helper-maven-plugin</artifactId>
214                <version>{build_helper}</version>
215                <executions>
216                    <execution>
217                        <id>add-test-source</id>
218                        <phase>generate-test-sources</phase>
219                        <goals>
220                            <goal>add-test-source</goal>
221                        </goals>
222                        <configuration>
223                            <sources>
224                                <source>src/test/java</source>
225                            </sources>
226                        </configuration>
227                    </execution>
228                </executions>
229            </plugin>
230            <plugin>
231                <groupId>org.apache.maven.plugins</groupId>
232                <artifactId>maven-surefire-plugin</artifactId>
233                <version>{maven_surefire}</version>
234                <configuration>
235                    <argLine>--enable-preview --enable-native-access=ALL-UNNAMED -Djava.library.path=../../target/release</argLine>
236                </configuration>
237            </plugin>
238        </plugins>
239    </build>
240</project>
241"#,
242        junit = tv::maven::JUNIT,
243        jackson = tv::maven::JACKSON_E2E,
244        build_helper = tv::maven::BUILD_HELPER_MAVEN_PLUGIN,
245        maven_surefire = tv::maven::MAVEN_SUREFIRE_PLUGIN_E2E,
246    )
247}
248
249#[allow(clippy::too_many_arguments)]
250fn render_test_file(
251    category: &str,
252    fixtures: &[&Fixture],
253    class_name: &str,
254    function_name: &str,
255    result_var: &str,
256    args: &[crate::config::ArgMapping],
257    options_type: Option<&str>,
258    field_resolver: &FieldResolver,
259    result_is_simple: bool,
260    enum_fields: &HashSet<String>,
261    e2e_config: &E2eConfig,
262) -> String {
263    let mut out = String::new();
264    out.push_str(&hash::header(CommentStyle::DoubleSlash));
265    let test_class_name = format!("{}Test", sanitize_filename(category).to_upper_camel_case());
266
267    // If the class_name is fully qualified (contains '.'), import it and use
268    // only the simple name for method calls.  Otherwise use it as-is.
269    let (import_path, simple_class) = if class_name.contains('.') {
270        let simple = class_name.rsplit('.').next().unwrap_or(class_name);
271        (class_name, simple)
272    } else {
273        ("", class_name)
274    };
275
276    let _ = writeln!(out, "package dev.kreuzberg.e2e;");
277    let _ = writeln!(out);
278
279    // Check if any fixture uses a json_object arg with options_type (needs ObjectMapper).
280    let needs_object_mapper_for_options = options_type.is_some()
281        && fixtures.iter().any(|f| {
282            args.iter().any(|arg| {
283                let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
284                arg.arg_type == "json_object" && f.input.get(field).is_some_and(|v| !v.is_null())
285            })
286        });
287    // Also need ObjectMapper when a handle arg has a non-null config.
288    let needs_object_mapper_for_handle = fixtures.iter().any(|f| {
289        args.iter().filter(|a| a.arg_type == "handle").any(|a| {
290            let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
291            !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
292        })
293    });
294    let needs_object_mapper = needs_object_mapper_for_options || needs_object_mapper_for_handle;
295
296    let _ = writeln!(out, "import org.junit.jupiter.api.Test;");
297    let _ = writeln!(out, "import static org.junit.jupiter.api.Assertions.*;");
298    if !import_path.is_empty() {
299        let _ = writeln!(out, "import {import_path};");
300    }
301    if needs_object_mapper {
302        let _ = writeln!(out, "import com.fasterxml.jackson.databind.ObjectMapper;");
303        let _ = writeln!(out, "import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;");
304    }
305    // Import the options type if tests use it (it's in the same package as the main class).
306    if let Some(opts_type) = options_type {
307        if needs_object_mapper {
308            // Derive the fully-qualified name from the main class import path.
309            let opts_package = if !import_path.is_empty() {
310                let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
311                format!("{pkg}.{opts_type}")
312            } else {
313                opts_type.to_string()
314            };
315            let _ = writeln!(out, "import {opts_package};");
316        }
317    }
318    // Import CrawlConfig when handle args need JSON deserialization.
319    if needs_object_mapper_for_handle && !import_path.is_empty() {
320        let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
321        let _ = writeln!(out, "import {pkg}.CrawlConfig;");
322    }
323    let _ = writeln!(out);
324
325    let _ = writeln!(out, "/** E2e tests for category: {category}. */");
326    let _ = writeln!(out, "class {test_class_name} {{");
327
328    if needs_object_mapper {
329        let _ = writeln!(out);
330        let _ = writeln!(
331            out,
332            "    private static final ObjectMapper MAPPER = new ObjectMapper().registerModule(new Jdk8Module());"
333        );
334    }
335
336    for fixture in fixtures {
337        render_test_method(
338            &mut out,
339            fixture,
340            simple_class,
341            function_name,
342            result_var,
343            args,
344            options_type,
345            field_resolver,
346            result_is_simple,
347            enum_fields,
348            e2e_config,
349        );
350        let _ = writeln!(out);
351    }
352
353    let _ = writeln!(out, "}}");
354    out
355}
356
357#[allow(clippy::too_many_arguments)]
358fn render_test_method(
359    out: &mut String,
360    fixture: &Fixture,
361    class_name: &str,
362    _function_name: &str,
363    _result_var: &str,
364    _args: &[crate::config::ArgMapping],
365    options_type: Option<&str>,
366    field_resolver: &FieldResolver,
367    result_is_simple: bool,
368    enum_fields: &HashSet<String>,
369    e2e_config: &E2eConfig,
370) {
371    // Resolve per-fixture call config (supports named calls via fixture.call field).
372    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
373    let lang = "java";
374    let call_overrides = call_config.overrides.get(lang);
375    let effective_function_name = call_overrides
376        .and_then(|o| o.function.as_ref())
377        .cloned()
378        .unwrap_or_else(|| call_config.function.to_lower_camel_case());
379    let effective_result_var = &call_config.result_var;
380    let effective_args = &call_config.args;
381    let function_name = effective_function_name.as_str();
382    let result_var = effective_result_var.as_str();
383    let args: &[crate::config::ArgMapping] = effective_args.as_slice();
384
385    let method_name = fixture.id.to_upper_camel_case();
386    let description = &fixture.description;
387    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
388
389    // Check if this test needs ObjectMapper deserialization for json_object args.
390    let needs_deser = options_type.is_some()
391        && args
392            .iter()
393            .any(|arg| arg.arg_type == "json_object" && fixture.input.get(&arg.field).is_some_and(|v| !v.is_null()));
394
395    // Always add throws Exception since the convert method may throw checked exceptions.
396    let throws_clause = " throws Exception";
397
398    let _ = writeln!(out, "    @Test");
399    let _ = writeln!(out, "    void test{method_name}(){throws_clause} {{");
400    let _ = writeln!(out, "        // {description}");
401
402    // Emit ObjectMapper deserialization bindings for json_object args.
403    if let (true, Some(opts_type)) = (needs_deser, options_type) {
404        for arg in args {
405            if arg.arg_type == "json_object" {
406                let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
407                if let Some(val) = fixture.input.get(field) {
408                    if !val.is_null() {
409                        // Fixture keys are camelCase; the Java ConversionOptions record uses
410                        // @JsonProperty("snake_case") annotations. Normalize keys so Jackson
411                        // can deserialize them correctly.
412                        let normalized = super::normalize_json_keys_to_snake_case(val);
413                        let json_str = serde_json::to_string(&normalized).unwrap_or_default();
414                        let var_name = &arg.name;
415                        let _ = writeln!(
416                            out,
417                            "        var {var_name} = MAPPER.readValue(\"{}\", {opts_type}.class);",
418                            escape_java(&json_str)
419                        );
420                    }
421                }
422            }
423        }
424    }
425
426    let (mut setup_lines, args_str) = build_args_and_setup(&fixture.input, args, class_name, options_type, &fixture.id);
427
428    // Build visitor if present and add to setup
429    let mut visitor_arg = String::new();
430    if let Some(visitor_spec) = &fixture.visitor {
431        visitor_arg = build_java_visitor(&mut setup_lines, visitor_spec, class_name);
432    }
433
434    for line in &setup_lines {
435        let _ = writeln!(out, "        {line}");
436    }
437
438    let final_args = if visitor_arg.is_empty() {
439        args_str
440    } else {
441        format!("{args_str}, {visitor_arg}")
442    };
443
444    if expects_error {
445        let _ = writeln!(
446            out,
447            "        assertThrows(Exception.class, () -> {class_name}.{function_name}({final_args}));"
448        );
449        let _ = writeln!(out, "    }}");
450        return;
451    }
452
453    let _ = writeln!(
454        out,
455        "        var {result_var} = {class_name}.{function_name}({final_args});"
456    );
457
458    // Emit a `source` variable for run_query assertions that need the raw bytes.
459    let needs_source_var = fixture
460        .assertions
461        .iter()
462        .any(|a| a.assertion_type == "method_result" && a.method.as_deref() == Some("run_query"));
463    if needs_source_var {
464        // Find the source_code arg to emit a `source` binding.
465        if let Some(source_arg) = args.iter().find(|a| a.field == "source_code") {
466            let field = source_arg.field.strip_prefix("input.").unwrap_or(&source_arg.field);
467            if let Some(val) = fixture.input.get(field) {
468                let java_val = json_to_java(val);
469                let _ = writeln!(out, "        var source = {java_val}.getBytes();");
470            }
471        }
472    }
473
474    for assertion in &fixture.assertions {
475        render_assertion(
476            out,
477            assertion,
478            result_var,
479            class_name,
480            field_resolver,
481            result_is_simple,
482            enum_fields,
483        );
484    }
485
486    let _ = writeln!(out, "    }}");
487}
488
489/// Build setup lines (e.g. handle creation) and the argument list for the function call.
490///
491/// Returns `(setup_lines, args_string)`.
492fn build_args_and_setup(
493    input: &serde_json::Value,
494    args: &[crate::config::ArgMapping],
495    class_name: &str,
496    options_type: Option<&str>,
497    fixture_id: &str,
498) -> (Vec<String>, String) {
499    if args.is_empty() {
500        return (Vec::new(), String::new());
501    }
502
503    let mut setup_lines: Vec<String> = Vec::new();
504    let mut parts: Vec<String> = Vec::new();
505
506    for arg in args {
507        if arg.arg_type == "mock_url" {
508            setup_lines.push(format!(
509                "String {} = System.getenv(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
510                arg.name,
511            ));
512            parts.push(arg.name.clone());
513            continue;
514        }
515
516        if arg.arg_type == "handle" {
517            // Generate a createEngine (or equivalent) call and pass the variable.
518            let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
519            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
520            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
521            if config_value.is_null()
522                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
523            {
524                setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
525            } else {
526                let json_str = serde_json::to_string(config_value).unwrap_or_default();
527                let name = &arg.name;
528                setup_lines.push(format!(
529                    "var {name}Config = MAPPER.readValue(\"{}\", CrawlConfig.class);",
530                    escape_java(&json_str),
531                ));
532                setup_lines.push(format!(
533                    "var {} = {class_name}.{constructor_name}({name}Config);",
534                    arg.name,
535                    name = name,
536                ));
537            }
538            parts.push(arg.name.clone());
539            continue;
540        }
541
542        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
543        let val = input.get(field);
544        match val {
545            None | Some(serde_json::Value::Null) if arg.optional => {
546                // Optional arg with no fixture value: skip entirely.
547                continue;
548            }
549            None | Some(serde_json::Value::Null) => {
550                // Required arg with no fixture value: pass a language-appropriate default.
551                let default_val = match arg.arg_type.as_str() {
552                    "string" => "\"\"".to_string(),
553                    "int" | "integer" => "0".to_string(),
554                    "float" | "number" => "0.0d".to_string(),
555                    "bool" | "boolean" => "false".to_string(),
556                    _ => "null".to_string(),
557                };
558                parts.push(default_val);
559            }
560            Some(v) => {
561                // For json_object args with options_type, use the pre-deserialized variable.
562                if arg.arg_type == "json_object" && options_type.is_some() {
563                    parts.push(arg.name.clone());
564                    continue;
565                }
566                // bytes args must be passed as byte[], not String.
567                if arg.arg_type == "bytes" {
568                    let val = json_to_java(v);
569                    parts.push(format!("{val}.getBytes()"));
570                    continue;
571                }
572                parts.push(json_to_java(v));
573            }
574        }
575    }
576
577    (setup_lines, parts.join(", "))
578}
579
580fn render_assertion(
581    out: &mut String,
582    assertion: &Assertion,
583    result_var: &str,
584    class_name: &str,
585    field_resolver: &FieldResolver,
586    result_is_simple: bool,
587    enum_fields: &HashSet<String>,
588) {
589    // Skip assertions on fields that don't exist on the result type.
590    if let Some(f) = &assertion.field {
591        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
592            let _ = writeln!(out, "        // skipped: field '{f}' not available on result type");
593            return;
594        }
595    }
596
597    // Determine if this field is an enum type (no `.contains()` on enums in Java).
598    // Check both the raw fixture field path and the resolved (aliased) path so that
599    // `fields_enum` entries can use either form (e.g., `"assets[].category"` or the
600    // resolved `"assets[].asset_category"`).
601    let field_is_enum = assertion
602        .field
603        .as_deref()
604        .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
605
606    let field_expr = if result_is_simple {
607        result_var.to_string()
608    } else {
609        match &assertion.field {
610            Some(f) if !f.is_empty() => {
611                let accessor = field_resolver.accessor(f, "java", result_var);
612                let resolved = field_resolver.resolve(f);
613                // Unwrap Optional fields with .orElse("") for string comparisons.
614                // Map.get() returns nullable, not Optional, so skip .orElse() for map access.
615                if field_resolver.is_optional(resolved) && !field_resolver.has_map_access(f) {
616                    format!("{accessor}.orElse(\"\")")
617                } else {
618                    accessor
619                }
620            }
621            _ => result_var.to_string(),
622        }
623    };
624
625    // For enum fields, string-based assertions need .getValue() to convert the enum to
626    // its serde-serialized lowercase string value (e.g., AssetCategory.Image -> "image").
627    // All alef-generated Java enums expose a getValue() method annotated with @JsonValue.
628    let string_expr = if field_is_enum {
629        format!("{field_expr}.getValue()")
630    } else {
631        field_expr.clone()
632    };
633
634    match assertion.assertion_type.as_str() {
635        "equals" => {
636            if let Some(expected) = &assertion.value {
637                let java_val = json_to_java(expected);
638                if expected.is_string() {
639                    let _ = writeln!(out, "        assertEquals({java_val}, {string_expr}.trim());");
640                } else {
641                    let _ = writeln!(out, "        assertEquals({java_val}, {field_expr});");
642                }
643            }
644        }
645        "contains" => {
646            if let Some(expected) = &assertion.value {
647                let java_val = json_to_java(expected);
648                let _ = writeln!(
649                    out,
650                    "        assertTrue({string_expr}.contains({java_val}), \"expected to contain: \" + {java_val});"
651                );
652            }
653        }
654        "contains_all" => {
655            if let Some(values) = &assertion.values {
656                for val in values {
657                    let java_val = json_to_java(val);
658                    let _ = writeln!(
659                        out,
660                        "        assertTrue({string_expr}.contains({java_val}), \"expected to contain: \" + {java_val});"
661                    );
662                }
663            }
664        }
665        "not_contains" => {
666            if let Some(expected) = &assertion.value {
667                let java_val = json_to_java(expected);
668                let _ = writeln!(
669                    out,
670                    "        assertFalse({string_expr}.contains({java_val}), \"expected NOT to contain: \" + {java_val});"
671                );
672            }
673        }
674        "not_empty" => {
675            let _ = writeln!(
676                out,
677                "        assertFalse({field_expr}.isEmpty(), \"expected non-empty value\");"
678            );
679        }
680        "is_empty" => {
681            let _ = writeln!(
682                out,
683                "        assertTrue({field_expr}.isEmpty(), \"expected empty value\");"
684            );
685        }
686        "contains_any" => {
687            if let Some(values) = &assertion.values {
688                let checks: Vec<String> = values
689                    .iter()
690                    .map(|v| {
691                        let java_val = json_to_java(v);
692                        format!("{string_expr}.contains({java_val})")
693                    })
694                    .collect();
695                let joined = checks.join(" || ");
696                let _ = writeln!(
697                    out,
698                    "        assertTrue({joined}, \"expected to contain at least one of the specified values\");"
699                );
700            }
701        }
702        "greater_than" => {
703            if let Some(val) = &assertion.value {
704                let java_val = json_to_java(val);
705                let _ = writeln!(
706                    out,
707                    "        assertTrue({field_expr} > {java_val}, \"expected > {java_val}\");"
708                );
709            }
710        }
711        "less_than" => {
712            if let Some(val) = &assertion.value {
713                let java_val = json_to_java(val);
714                let _ = writeln!(
715                    out,
716                    "        assertTrue({field_expr} < {java_val}, \"expected < {java_val}\");"
717                );
718            }
719        }
720        "greater_than_or_equal" => {
721            if let Some(val) = &assertion.value {
722                let java_val = json_to_java(val);
723                let _ = writeln!(
724                    out,
725                    "        assertTrue({field_expr} >= {java_val}, \"expected >= {java_val}\");"
726                );
727            }
728        }
729        "less_than_or_equal" => {
730            if let Some(val) = &assertion.value {
731                let java_val = json_to_java(val);
732                let _ = writeln!(
733                    out,
734                    "        assertTrue({field_expr} <= {java_val}, \"expected <= {java_val}\");"
735                );
736            }
737        }
738        "starts_with" => {
739            if let Some(expected) = &assertion.value {
740                let java_val = json_to_java(expected);
741                let _ = writeln!(
742                    out,
743                    "        assertTrue({string_expr}.startsWith({java_val}), \"expected to start with: \" + {java_val});"
744                );
745            }
746        }
747        "ends_with" => {
748            if let Some(expected) = &assertion.value {
749                let java_val = json_to_java(expected);
750                let _ = writeln!(
751                    out,
752                    "        assertTrue({string_expr}.endsWith({java_val}), \"expected to end with: \" + {java_val});"
753                );
754            }
755        }
756        "min_length" => {
757            if let Some(val) = &assertion.value {
758                if let Some(n) = val.as_u64() {
759                    let _ = writeln!(
760                        out,
761                        "        assertTrue({field_expr}.length() >= {n}, \"expected length >= {n}\");"
762                    );
763                }
764            }
765        }
766        "max_length" => {
767            if let Some(val) = &assertion.value {
768                if let Some(n) = val.as_u64() {
769                    let _ = writeln!(
770                        out,
771                        "        assertTrue({field_expr}.length() <= {n}, \"expected length <= {n}\");"
772                    );
773                }
774            }
775        }
776        "count_min" => {
777            if let Some(val) = &assertion.value {
778                if let Some(n) = val.as_u64() {
779                    let _ = writeln!(
780                        out,
781                        "        assertTrue({field_expr}.size() >= {n}, \"expected at least {n} elements\");"
782                    );
783                }
784            }
785        }
786        "count_equals" => {
787            if let Some(val) = &assertion.value {
788                if let Some(n) = val.as_u64() {
789                    let _ = writeln!(
790                        out,
791                        "        assertEquals({n}, {field_expr}.size(), \"expected exactly {n} elements\");"
792                    );
793                }
794            }
795        }
796        "is_true" => {
797            let _ = writeln!(out, "        assertTrue({field_expr}, \"expected true\");");
798        }
799        "is_false" => {
800            let _ = writeln!(out, "        assertFalse({field_expr}, \"expected false\");");
801        }
802        "method_result" => {
803            if let Some(method_name) = &assertion.method {
804                let call_expr = build_java_method_call(result_var, method_name, assertion.args.as_ref(), class_name);
805                let check = assertion.check.as_deref().unwrap_or("is_true");
806                // Methods that return a collection (List) rather than a scalar.
807                let method_returns_collection =
808                    matches!(method_name.as_str(), "find_nodes_by_type" | "findNodesByType");
809                match check {
810                    "equals" => {
811                        if let Some(val) = &assertion.value {
812                            if val.is_boolean() {
813                                if val.as_bool() == Some(true) {
814                                    let _ = writeln!(out, "        assertTrue({call_expr});");
815                                } else {
816                                    let _ = writeln!(out, "        assertFalse({call_expr});");
817                                }
818                            } else if method_returns_collection {
819                                let java_val = json_to_java(val);
820                                let _ = writeln!(out, "        assertEquals({java_val}, {call_expr}.size());");
821                            } else {
822                                let java_val = json_to_java(val);
823                                let _ = writeln!(out, "        assertEquals({java_val}, {call_expr});");
824                            }
825                        }
826                    }
827                    "is_true" => {
828                        let _ = writeln!(out, "        assertTrue({call_expr});");
829                    }
830                    "is_false" => {
831                        let _ = writeln!(out, "        assertFalse({call_expr});");
832                    }
833                    "greater_than_or_equal" => {
834                        if let Some(val) = &assertion.value {
835                            let n = val.as_u64().unwrap_or(0);
836                            let _ = writeln!(out, "        assertTrue({call_expr} >= {n}, \"expected >= {n}\");");
837                        }
838                    }
839                    "count_min" => {
840                        if let Some(val) = &assertion.value {
841                            let n = val.as_u64().unwrap_or(0);
842                            let _ = writeln!(
843                                out,
844                                "        assertTrue({call_expr}.size() >= {n}, \"expected at least {n} elements\");"
845                            );
846                        }
847                    }
848                    "is_error" => {
849                        let _ = writeln!(out, "        assertThrows(Exception.class, () -> {{ {call_expr}; }});");
850                    }
851                    "contains" => {
852                        if let Some(val) = &assertion.value {
853                            let java_val = json_to_java(val);
854                            let _ = writeln!(
855                                out,
856                                "        assertTrue({call_expr}.contains({java_val}), \"expected to contain: \" + {java_val});"
857                            );
858                        }
859                    }
860                    other_check => {
861                        panic!("Java e2e generator: unsupported method_result check type: {other_check}");
862                    }
863                }
864            } else {
865                panic!("Java e2e generator: method_result assertion missing 'method' field");
866            }
867        }
868        "matches_regex" => {
869            if let Some(expected) = &assertion.value {
870                let java_val = json_to_java(expected);
871                let _ = writeln!(
872                    out,
873                    "        assertTrue({string_expr}.matches({java_val}), \"expected value to match regex: \" + {java_val});"
874                );
875            }
876        }
877        "not_error" => {
878            // Already handled by the call succeeding without exception.
879        }
880        "error" => {
881            // Handled at the test method level.
882        }
883        other => {
884            panic!("Java e2e generator: unsupported assertion type: {other}");
885        }
886    }
887}
888
889/// Build a Java call expression for a `method_result` assertion on a tree-sitter Tree.
890///
891/// Maps method names to the appropriate Java static/instance method calls.
892fn build_java_method_call(
893    result_var: &str,
894    method_name: &str,
895    args: Option<&serde_json::Value>,
896    class_name: &str,
897) -> String {
898    match method_name {
899        "root_child_count" => format!("{result_var}.rootNode().childCount()"),
900        "root_node_type" => format!("{result_var}.rootNode().kind()"),
901        "named_children_count" => format!("{result_var}.rootNode().namedChildCount()"),
902        "has_error_nodes" => format!("{class_name}.treeHasErrorNodes({result_var})"),
903        "error_count" | "tree_error_count" => format!("{class_name}.treeErrorCount({result_var})"),
904        "tree_to_sexp" => format!("{class_name}.treeToSexp({result_var})"),
905        "contains_node_type" => {
906            let node_type = args
907                .and_then(|a| a.get("node_type"))
908                .and_then(|v| v.as_str())
909                .unwrap_or("");
910            format!("{class_name}.treeContainsNodeType({result_var}, \"{node_type}\")")
911        }
912        "find_nodes_by_type" => {
913            let node_type = args
914                .and_then(|a| a.get("node_type"))
915                .and_then(|v| v.as_str())
916                .unwrap_or("");
917            format!("{class_name}.findNodesByType({result_var}, \"{node_type}\")")
918        }
919        "run_query" => {
920            let query_source = args
921                .and_then(|a| a.get("query_source"))
922                .and_then(|v| v.as_str())
923                .unwrap_or("");
924            let language = args
925                .and_then(|a| a.get("language"))
926                .and_then(|v| v.as_str())
927                .unwrap_or("");
928            let escaped_query = escape_java(query_source);
929            format!("{class_name}.runQuery({result_var}, \"{language}\", \"{escaped_query}\", source)")
930        }
931        _ => {
932            format!("{result_var}.{}()", method_name.to_lower_camel_case())
933        }
934    }
935}
936
937/// Convert a `serde_json::Value` to a Java literal string.
938fn json_to_java(value: &serde_json::Value) -> String {
939    match value {
940        serde_json::Value::String(s) => format!("\"{}\"", escape_java(s)),
941        serde_json::Value::Bool(b) => b.to_string(),
942        serde_json::Value::Number(n) => {
943            if n.is_f64() {
944                format!("{}d", n)
945            } else {
946                n.to_string()
947            }
948        }
949        serde_json::Value::Null => "null".to_string(),
950        serde_json::Value::Array(arr) => {
951            let items: Vec<String> = arr.iter().map(json_to_java).collect();
952            format!("java.util.List.of({})", items.join(", "))
953        }
954        serde_json::Value::Object(_) => {
955            let json_str = serde_json::to_string(value).unwrap_or_default();
956            format!("\"{}\"", escape_java(&json_str))
957        }
958    }
959}
960
961// ---------------------------------------------------------------------------
962// Visitor generation
963// ---------------------------------------------------------------------------
964
965/// Build a Java visitor class and add setup lines. Returns the visitor variable name.
966fn build_java_visitor(
967    setup_lines: &mut Vec<String>,
968    visitor_spec: &crate::fixture::VisitorSpec,
969    class_name: &str,
970) -> String {
971    setup_lines.push("class _TestVisitor implements TestVisitor {".to_string());
972    for (method_name, action) in &visitor_spec.callbacks {
973        emit_java_visitor_method(setup_lines, method_name, action, class_name);
974    }
975    setup_lines.push("}".to_string());
976    setup_lines.push("var visitor = new _TestVisitor();".to_string());
977    "visitor".to_string()
978}
979
980/// Emit a Java visitor method for a callback action.
981fn emit_java_visitor_method(
982    setup_lines: &mut Vec<String>,
983    method_name: &str,
984    action: &CallbackAction,
985    _class_name: &str,
986) {
987    let camel_method = method_to_camel(method_name);
988    let params = match method_name {
989        "visit_link" => "VisitContext ctx, String href, String text, String title",
990        "visit_image" => "VisitContext ctx, String src, String alt, String title",
991        "visit_heading" => "VisitContext ctx, int level, String text, String id",
992        "visit_code_block" => "VisitContext ctx, String lang, String code",
993        "visit_code_inline"
994        | "visit_strong"
995        | "visit_emphasis"
996        | "visit_strikethrough"
997        | "visit_underline"
998        | "visit_subscript"
999        | "visit_superscript"
1000        | "visit_mark"
1001        | "visit_button"
1002        | "visit_summary"
1003        | "visit_figcaption"
1004        | "visit_definition_term"
1005        | "visit_definition_description" => "VisitContext ctx, String text",
1006        "visit_text" => "VisitContext ctx, String text",
1007        "visit_list_item" => "VisitContext ctx, boolean ordered, String marker, String text",
1008        "visit_blockquote" => "VisitContext ctx, String content, int depth",
1009        "visit_table_row" => "VisitContext ctx, java.util.List<String> cells, boolean isHeader",
1010        "visit_custom_element" => "VisitContext ctx, String tagName, String html",
1011        "visit_form" => "VisitContext ctx, String actionUrl, String method",
1012        "visit_input" => "VisitContext ctx, String inputType, String name, String value",
1013        "visit_audio" | "visit_video" | "visit_iframe" => "VisitContext ctx, String src",
1014        "visit_details" => "VisitContext ctx, boolean isOpen",
1015        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
1016            "VisitContext ctx, String output"
1017        }
1018        "visit_list_start" => "VisitContext ctx, boolean ordered",
1019        "visit_list_end" => "VisitContext ctx, boolean ordered, String output",
1020        _ => "VisitContext ctx",
1021    };
1022
1023    setup_lines.push(format!("    @Override public VisitResult {camel_method}({params}) {{"));
1024    match action {
1025        CallbackAction::Skip => {
1026            setup_lines.push("        return VisitResult.skip();".to_string());
1027        }
1028        CallbackAction::Continue => {
1029            setup_lines.push("        return VisitResult.continue_();".to_string());
1030        }
1031        CallbackAction::PreserveHtml => {
1032            setup_lines.push("        return VisitResult.preserveHtml();".to_string());
1033        }
1034        CallbackAction::Custom { output } => {
1035            let escaped = escape_java(output);
1036            setup_lines.push(format!("        return VisitResult.custom(\"{escaped}\");"));
1037        }
1038        CallbackAction::CustomTemplate { template } => {
1039            let escaped = escape_java(template);
1040            setup_lines.push(format!(
1041                "        return VisitResult.custom(String.format(\"{escaped}\"));"
1042            ));
1043        }
1044    }
1045    setup_lines.push("    }".to_string());
1046}
1047
1048/// Convert snake_case method names to Java camelCase.
1049fn method_to_camel(snake: &str) -> String {
1050    snake.to_lower_camel_case()
1051}