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