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