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