1use 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
22pub 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 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 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 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 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 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 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
133fn 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 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 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 let lang_for_om = "java";
285 let needs_object_mapper_for_options = fixtures.iter().any(|f| {
286 let call_cfg = e2e_config.resolve_call(f.call.as_deref());
287 let eff_opts = call_cfg
288 .overrides
289 .get(lang_for_om)
290 .and_then(|o| o.options_type.as_deref())
291 .or(options_type);
292 if eff_opts.is_none() {
293 return false;
294 }
295 call_cfg.args.iter().any(|arg| {
296 if arg.arg_type != "json_object" {
297 return false;
298 }
299 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
300 let val = f.input.get(field);
301 match val {
304 None | Some(serde_json::Value::Null) => arg.optional, Some(v) => !v.is_array(), }
307 })
308 });
309 let needs_object_mapper_for_handle = fixtures.iter().any(|f| {
311 args.iter().filter(|a| a.arg_type == "handle").any(|a| {
312 let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
313 !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
314 })
315 });
316 let needs_object_mapper = needs_object_mapper_for_options || needs_object_mapper_for_handle;
317
318 let mut all_options_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
320 if let Some(t) = options_type {
321 all_options_types.insert(t.to_string());
322 }
323 for f in fixtures.iter() {
324 let call_cfg = e2e_config.resolve_call(f.call.as_deref());
325 if let Some(ov) = call_cfg.overrides.get(lang_for_om) {
326 if let Some(t) = &ov.options_type {
327 all_options_types.insert(t.clone());
328 }
329 }
330 }
331
332 let _ = writeln!(out, "import org.junit.jupiter.api.Test;");
333 let _ = writeln!(out, "import static org.junit.jupiter.api.Assertions.*;");
334 if !import_path.is_empty() {
335 let _ = writeln!(out, "import {import_path};");
336 }
337 if needs_object_mapper {
338 let _ = writeln!(out, "import com.fasterxml.jackson.databind.ObjectMapper;");
339 let _ = writeln!(out, "import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;");
340 }
341 if needs_object_mapper && !all_options_types.is_empty() {
343 let opts_pkg = if !import_path.is_empty() {
344 import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("")
345 } else {
346 ""
347 };
348 for opts_type in &all_options_types {
349 let qualified = if opts_pkg.is_empty() {
350 opts_type.clone()
351 } else {
352 format!("{opts_pkg}.{opts_type}")
353 };
354 let _ = writeln!(out, "import {qualified};");
355 }
356 }
357 if needs_object_mapper_for_handle && !import_path.is_empty() {
359 let pkg = import_path.rsplit_once('.').map(|(p, _)| p).unwrap_or("");
360 let _ = writeln!(out, "import {pkg}.CrawlConfig;");
361 }
362 let _ = writeln!(out);
363
364 let _ = writeln!(out, "/** E2e tests for category: {category}. */");
365 let _ = writeln!(out, "class {test_class_name} {{");
366
367 if needs_object_mapper {
368 let _ = writeln!(out);
369 let _ = writeln!(
370 out,
371 " private static final ObjectMapper MAPPER = new ObjectMapper().registerModule(new Jdk8Module());"
372 );
373 }
374
375 for fixture in fixtures {
376 render_test_method(
377 &mut out,
378 fixture,
379 simple_class,
380 function_name,
381 result_var,
382 args,
383 options_type,
384 field_resolver,
385 result_is_simple,
386 enum_fields,
387 e2e_config,
388 );
389 let _ = writeln!(out);
390 }
391
392 let _ = writeln!(out, "}}");
393 out
394}
395
396#[allow(clippy::too_many_arguments)]
397fn render_test_method(
398 out: &mut String,
399 fixture: &Fixture,
400 class_name: &str,
401 _function_name: &str,
402 _result_var: &str,
403 _args: &[crate::config::ArgMapping],
404 options_type: Option<&str>,
405 field_resolver: &FieldResolver,
406 result_is_simple: bool,
407 enum_fields: &HashSet<String>,
408 e2e_config: &E2eConfig,
409) {
410 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
412 let lang = "java";
413 let call_overrides = call_config.overrides.get(lang);
414 let effective_function_name = call_overrides
415 .and_then(|o| o.function.as_ref())
416 .cloned()
417 .unwrap_or_else(|| call_config.function.to_lower_camel_case());
418 let effective_result_var = &call_config.result_var;
419 let effective_args = &call_config.args;
420 let function_name = effective_function_name.as_str();
421 let result_var = effective_result_var.as_str();
422 let args: &[crate::config::ArgMapping] = effective_args.as_slice();
423
424 let method_name = fixture.id.to_upper_camel_case();
425 let description = &fixture.description;
426 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
427
428 let effective_options_type: Option<String> = call_overrides
430 .and_then(|o| o.options_type.clone())
431 .or_else(|| options_type.map(|s| s.to_string()));
432 let effective_options_type = effective_options_type.as_deref();
433
434 let effective_result_is_simple = call_overrides.is_some_and(|o| o.result_is_simple) || result_is_simple;
436 let effective_result_is_bytes = call_overrides.is_some_and(|o| o.result_is_bytes);
437
438 let needs_deser = effective_options_type.is_some()
441 && args.iter().any(|arg| {
442 if arg.arg_type != "json_object" {
443 return false;
444 }
445 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
446 fixture.input.get(field).is_some_and(|v| !v.is_null() && !v.is_array())
447 });
448
449 let throws_clause = " throws Exception";
451
452 let _ = writeln!(out, " @Test");
453 let _ = writeln!(out, " void test{method_name}(){throws_clause} {{");
454 let _ = writeln!(out, " // {description}");
455
456 if let (true, Some(opts_type)) = (needs_deser, effective_options_type) {
458 for arg in args {
459 if arg.arg_type == "json_object" {
460 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
461 if let Some(val) = fixture.input.get(field) {
462 if !val.is_null() && !val.is_array() {
463 let normalized = super::normalize_json_keys_to_snake_case(val);
467 let json_str = serde_json::to_string(&normalized).unwrap_or_default();
468 let var_name = &arg.name;
469 let _ = writeln!(
470 out,
471 " var {var_name} = MAPPER.readValue(\"{}\", {opts_type}.class);",
472 escape_java(&json_str)
473 );
474 }
475 }
476 }
477 }
478 }
479
480 let (mut setup_lines, args_str) =
481 build_args_and_setup(&fixture.input, args, class_name, effective_options_type, &fixture.id);
482
483 let mut visitor_arg = String::new();
485 if let Some(visitor_spec) = &fixture.visitor {
486 visitor_arg = build_java_visitor(&mut setup_lines, visitor_spec, class_name);
487 }
488
489 for line in &setup_lines {
490 let _ = writeln!(out, " {line}");
491 }
492
493 let final_args = if visitor_arg.is_empty() {
494 args_str
495 } else {
496 format!("{args_str}, {visitor_arg}")
497 };
498
499 if expects_error {
500 let _ = writeln!(
501 out,
502 " assertThrows(Exception.class, () -> {class_name}.{function_name}({final_args}));"
503 );
504 let _ = writeln!(out, " }}");
505 return;
506 }
507
508 let _ = writeln!(
509 out,
510 " var {result_var} = {class_name}.{function_name}({final_args});"
511 );
512
513 let needs_source_var = fixture
515 .assertions
516 .iter()
517 .any(|a| a.assertion_type == "method_result" && a.method.as_deref() == Some("run_query"));
518 if needs_source_var {
519 if let Some(source_arg) = args.iter().find(|a| a.field == "source_code") {
521 let field = source_arg.field.strip_prefix("input.").unwrap_or(&source_arg.field);
522 if let Some(val) = fixture.input.get(field) {
523 let java_val = json_to_java(val);
524 let _ = writeln!(out, " var source = {java_val}.getBytes();");
525 }
526 }
527 }
528
529 for assertion in &fixture.assertions {
530 render_assertion(
531 out,
532 assertion,
533 result_var,
534 class_name,
535 field_resolver,
536 effective_result_is_simple,
537 effective_result_is_bytes,
538 enum_fields,
539 );
540 }
541
542 let _ = writeln!(out, " }}");
543}
544
545fn build_args_and_setup(
549 input: &serde_json::Value,
550 args: &[crate::config::ArgMapping],
551 class_name: &str,
552 options_type: Option<&str>,
553 fixture_id: &str,
554) -> (Vec<String>, String) {
555 if args.is_empty() {
556 return (Vec::new(), String::new());
557 }
558
559 let mut setup_lines: Vec<String> = Vec::new();
560 let mut parts: Vec<String> = Vec::new();
561
562 for arg in args {
563 if arg.arg_type == "mock_url" {
564 setup_lines.push(format!(
565 "String {} = System.getenv(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
566 arg.name,
567 ));
568 parts.push(arg.name.clone());
569 continue;
570 }
571
572 if arg.arg_type == "handle" {
573 let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
575 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
576 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
577 if config_value.is_null()
578 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
579 {
580 setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
581 } else {
582 let json_str = serde_json::to_string(config_value).unwrap_or_default();
583 let name = &arg.name;
584 setup_lines.push(format!(
585 "var {name}Config = MAPPER.readValue(\"{}\", CrawlConfig.class);",
586 escape_java(&json_str),
587 ));
588 setup_lines.push(format!(
589 "var {} = {class_name}.{constructor_name}({name}Config);",
590 arg.name,
591 name = name,
592 ));
593 }
594 parts.push(arg.name.clone());
595 continue;
596 }
597
598 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
599 let val = input.get(field);
600 match val {
601 None | Some(serde_json::Value::Null) if arg.optional => {
602 if arg.arg_type == "json_object" {
606 if let Some(opts_type) = options_type {
607 parts.push(format!("MAPPER.readValue(\"{{}}\", {opts_type}.class)"));
608 } else {
609 parts.push("null".to_string());
610 }
611 } else {
612 parts.push("null".to_string());
613 }
614 }
615 None | Some(serde_json::Value::Null) => {
616 let default_val = match arg.arg_type.as_str() {
618 "string" | "file_path" => "\"\"".to_string(),
619 "int" | "integer" => "0".to_string(),
620 "float" | "number" => "0.0d".to_string(),
621 "bool" | "boolean" => "false".to_string(),
622 _ => "null".to_string(),
623 };
624 parts.push(default_val);
625 }
626 Some(v) => {
627 if arg.arg_type == "json_object" {
628 if v.is_array() {
631 let elem_type = arg.element_type.as_deref();
632 parts.push(json_to_java_typed(v, elem_type));
633 continue;
634 }
635 if options_type.is_some() {
637 parts.push(arg.name.clone());
638 continue;
639 }
640 parts.push(json_to_java(v));
641 continue;
642 }
643 if arg.arg_type == "bytes" {
645 let val = json_to_java(v);
646 parts.push(format!("{val}.getBytes()"));
647 continue;
648 }
649 if arg.arg_type == "file_path" {
651 let val = json_to_java(v);
652 parts.push(format!("java.nio.file.Path.of({val})"));
653 continue;
654 }
655 parts.push(json_to_java(v));
656 }
657 }
658 }
659
660 (setup_lines, parts.join(", "))
661}
662
663fn render_assertion(
664 out: &mut String,
665 assertion: &Assertion,
666 result_var: &str,
667 class_name: &str,
668 field_resolver: &FieldResolver,
669 result_is_simple: bool,
670 result_is_bytes: bool,
671 enum_fields: &HashSet<String>,
672) {
673 if let Some(f) = &assertion.field {
675 match f.as_str() {
676 "chunks_have_content" => {
678 let pred = format!(
679 "{result_var}.chunks().orElse(java.util.List.of()).stream().allMatch(c -> c.content() != null && !c.content().isBlank())"
680 );
681 match assertion.assertion_type.as_str() {
682 "is_true" => {
683 let _ = writeln!(out, " assertTrue({pred}, \"expected true\");");
684 }
685 "is_false" => {
686 let _ = writeln!(out, " assertFalse({pred}, \"expected false\");");
687 }
688 _ => {
689 let _ = writeln!(
690 out,
691 " // skipped: unsupported assertion on synthetic field '{f}'"
692 );
693 }
694 }
695 return;
696 }
697 "chunks_have_heading_context" => {
698 let pred = format!(
699 "{result_var}.chunks().orElse(java.util.List.of()).stream().allMatch(c -> c.metadata().headingContext().isPresent())"
700 );
701 match assertion.assertion_type.as_str() {
702 "is_true" => {
703 let _ = writeln!(out, " assertTrue({pred}, \"expected true\");");
704 }
705 "is_false" => {
706 let _ = writeln!(out, " assertFalse({pred}, \"expected false\");");
707 }
708 _ => {
709 let _ = writeln!(
710 out,
711 " // skipped: unsupported assertion on synthetic field '{f}'"
712 );
713 }
714 }
715 return;
716 }
717 "chunks_have_embeddings" => {
718 let pred = format!(
719 "{result_var}.chunks().orElse(java.util.List.of()).stream().allMatch(c -> c.embedding() != null && !c.embedding().isEmpty())"
720 );
721 match assertion.assertion_type.as_str() {
722 "is_true" => {
723 let _ = writeln!(out, " assertTrue({pred}, \"expected true\");");
724 }
725 "is_false" => {
726 let _ = writeln!(out, " assertFalse({pred}, \"expected false\");");
727 }
728 _ => {
729 let _ = writeln!(
730 out,
731 " // skipped: unsupported assertion on synthetic field '{f}'"
732 );
733 }
734 }
735 return;
736 }
737 "first_chunk_starts_with_heading" => {
738 let pred = format!(
739 "{result_var}.chunks().orElse(java.util.List.of()).stream().findFirst().map(c -> c.metadata().headingContext().isPresent()).orElse(false)"
740 );
741 match assertion.assertion_type.as_str() {
742 "is_true" => {
743 let _ = writeln!(out, " assertTrue({pred}, \"expected true\");");
744 }
745 "is_false" => {
746 let _ = writeln!(out, " assertFalse({pred}, \"expected false\");");
747 }
748 _ => {
749 let _ = writeln!(
750 out,
751 " // skipped: unsupported assertion on synthetic field '{f}'"
752 );
753 }
754 }
755 return;
756 }
757 "embedding_dimensions" => {
761 let embed_list = if result_is_simple {
763 result_var.to_string()
764 } else {
765 format!("{result_var}.embeddings()")
766 };
767 let expr = format!("({embed_list}.isEmpty() ? 0 : {embed_list}.get(0).size())");
768 match assertion.assertion_type.as_str() {
769 "equals" => {
770 if let Some(val) = &assertion.value {
771 let java_val = json_to_java(val);
772 let _ = writeln!(out, " assertEquals({java_val}, {expr});");
773 }
774 }
775 "greater_than" => {
776 if let Some(val) = &assertion.value {
777 let java_val = json_to_java(val);
778 let _ = writeln!(
779 out,
780 " assertTrue({expr} > {java_val}, \"expected > {java_val}\");"
781 );
782 }
783 }
784 _ => {
785 let _ = writeln!(out, " // skipped: unsupported assertion on '{f}'");
786 }
787 }
788 return;
789 }
790 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
791 let embed_list = if result_is_simple {
793 result_var.to_string()
794 } else {
795 format!("{result_var}.embeddings()")
796 };
797 let pred = match f.as_str() {
798 "embeddings_valid" => {
799 format!("{embed_list}.stream().allMatch(e -> e != null && !e.isEmpty())")
800 }
801 "embeddings_finite" => {
802 format!("{embed_list}.stream().flatMap(java.util.Collection::stream).allMatch(Float::isFinite)")
803 }
804 "embeddings_non_zero" => {
805 format!("{embed_list}.stream().allMatch(e -> e.stream().anyMatch(v -> v != 0.0f))")
806 }
807 "embeddings_normalized" => format!(
808 "{embed_list}.stream().allMatch(e -> {{ double n = e.stream().mapToDouble(v -> v * v).sum(); return Math.abs(n - 1.0) < 1e-3; }})"
809 ),
810 _ => unreachable!(),
811 };
812 match assertion.assertion_type.as_str() {
813 "is_true" => {
814 let _ = writeln!(out, " assertTrue({pred}, \"expected true\");");
815 }
816 "is_false" => {
817 let _ = writeln!(out, " assertFalse({pred}, \"expected false\");");
818 }
819 _ => {
820 let _ = writeln!(out, " // skipped: unsupported assertion on '{f}'");
821 }
822 }
823 return;
824 }
825 "keywords" | "keywords_count" => {
827 let _ = writeln!(
828 out,
829 " // skipped: field '{f}' not available on Java ExtractionResult"
830 );
831 return;
832 }
833 "metadata" => {
836 match assertion.assertion_type.as_str() {
837 "not_empty" => {
838 let _ = writeln!(
839 out,
840 " assertTrue({result_var}.metadata().title().isPresent() || {result_var}.metadata().subject().isPresent() || !{result_var}.metadata().additional().isEmpty(), \"expected non-empty value\");"
841 );
842 return;
843 }
844 "is_empty" => {
845 let _ = writeln!(
846 out,
847 " assertFalse({result_var}.metadata().title().isPresent() || {result_var}.metadata().subject().isPresent() || !{result_var}.metadata().additional().isEmpty(), \"expected empty value\");"
848 );
849 return;
850 }
851 _ => {} }
853 }
854 _ => {}
855 }
856 }
857
858 if let Some(f) = &assertion.field {
860 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
861 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
862 return;
863 }
864 }
865
866 let field_is_enum = assertion
871 .field
872 .as_deref()
873 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
874
875 let field_expr = if result_is_simple {
876 result_var.to_string()
877 } else {
878 match &assertion.field {
879 Some(f) if !f.is_empty() => {
880 let accessor = field_resolver.accessor(f, "java", result_var);
881 let resolved = field_resolver.resolve(f);
882 if field_resolver.is_optional(resolved) && !field_resolver.has_map_access(f) {
885 match assertion.assertion_type.as_str() {
887 "not_empty" | "is_empty" => accessor,
890 "count_min" | "count_equals" => {
892 format!("{accessor}.orElse(java.util.List.of())")
893 }
894 "greater_than" | "less_than" | "greater_than_or_equal" | "less_than_or_equal" => {
896 if field_resolver.is_array(resolved) {
897 format!("{accessor}.orElse(java.util.List.of())")
898 } else {
899 format!("{accessor}.orElse(0L)")
900 }
901 }
902 _ if field_resolver.is_array(resolved) => {
903 format!("{accessor}.orElse(java.util.List.of())")
904 }
905 _ => format!("{accessor}.orElse(\"\")"),
906 }
907 } else {
908 accessor
909 }
910 }
911 _ => result_var.to_string(),
912 }
913 };
914
915 let string_expr = if field_is_enum {
919 format!("{field_expr}.getValue()")
920 } else {
921 field_expr.clone()
922 };
923
924 match assertion.assertion_type.as_str() {
925 "equals" => {
926 if let Some(expected) = &assertion.value {
927 let java_val = json_to_java(expected);
928 if expected.is_string() {
929 let _ = writeln!(out, " assertEquals({java_val}, {string_expr}.trim());");
930 } else {
931 let _ = writeln!(out, " assertEquals({java_val}, {field_expr});");
932 }
933 }
934 }
935 "contains" => {
936 if let Some(expected) = &assertion.value {
937 let java_val = json_to_java(expected);
938 let _ = writeln!(
939 out,
940 " assertTrue({string_expr}.contains({java_val}), \"expected to contain: \" + {java_val});"
941 );
942 }
943 }
944 "contains_all" => {
945 if let Some(values) = &assertion.values {
946 for val in values {
947 let java_val = json_to_java(val);
948 let _ = writeln!(
949 out,
950 " assertTrue({string_expr}.contains({java_val}), \"expected to contain: \" + {java_val});"
951 );
952 }
953 }
954 }
955 "not_contains" => {
956 if let Some(expected) = &assertion.value {
957 let java_val = json_to_java(expected);
958 let _ = writeln!(
959 out,
960 " assertFalse({string_expr}.contains({java_val}), \"expected NOT to contain: \" + {java_val});"
961 );
962 }
963 }
964 "not_empty" => {
965 let _ = writeln!(
966 out,
967 " assertFalse({field_expr}.isEmpty(), \"expected non-empty value\");"
968 );
969 }
970 "is_empty" => {
971 let _ = writeln!(
972 out,
973 " assertTrue({field_expr}.isEmpty(), \"expected empty value\");"
974 );
975 }
976 "contains_any" => {
977 if let Some(values) = &assertion.values {
978 let checks: Vec<String> = values
979 .iter()
980 .map(|v| {
981 let java_val = json_to_java(v);
982 format!("{string_expr}.contains({java_val})")
983 })
984 .collect();
985 let joined = checks.join(" || ");
986 let _ = writeln!(
987 out,
988 " assertTrue({joined}, \"expected to contain at least one of the specified values\");"
989 );
990 }
991 }
992 "greater_than" => {
993 if let Some(val) = &assertion.value {
994 let java_val = json_to_java(val);
995 let _ = writeln!(
996 out,
997 " assertTrue({field_expr} > {java_val}, \"expected > {java_val}\");"
998 );
999 }
1000 }
1001 "less_than" => {
1002 if let Some(val) = &assertion.value {
1003 let java_val = json_to_java(val);
1004 let _ = writeln!(
1005 out,
1006 " assertTrue({field_expr} < {java_val}, \"expected < {java_val}\");"
1007 );
1008 }
1009 }
1010 "greater_than_or_equal" => {
1011 if let Some(val) = &assertion.value {
1012 let java_val = json_to_java(val);
1013 let _ = writeln!(
1014 out,
1015 " assertTrue({field_expr} >= {java_val}, \"expected >= {java_val}\");"
1016 );
1017 }
1018 }
1019 "less_than_or_equal" => {
1020 if let Some(val) = &assertion.value {
1021 let java_val = json_to_java(val);
1022 let _ = writeln!(
1023 out,
1024 " assertTrue({field_expr} <= {java_val}, \"expected <= {java_val}\");"
1025 );
1026 }
1027 }
1028 "starts_with" => {
1029 if let Some(expected) = &assertion.value {
1030 let java_val = json_to_java(expected);
1031 let _ = writeln!(
1032 out,
1033 " assertTrue({string_expr}.startsWith({java_val}), \"expected to start with: \" + {java_val});"
1034 );
1035 }
1036 }
1037 "ends_with" => {
1038 if let Some(expected) = &assertion.value {
1039 let java_val = json_to_java(expected);
1040 let _ = writeln!(
1041 out,
1042 " assertTrue({string_expr}.endsWith({java_val}), \"expected to end with: \" + {java_val});"
1043 );
1044 }
1045 }
1046 "min_length" => {
1047 if let Some(val) = &assertion.value {
1048 if let Some(n) = val.as_u64() {
1049 let len_expr = if result_is_bytes {
1051 format!("{field_expr}.length")
1052 } else {
1053 format!("{field_expr}.length()")
1054 };
1055 let _ = writeln!(
1056 out,
1057 " assertTrue({len_expr} >= {n}, \"expected length >= {n}\");"
1058 );
1059 }
1060 }
1061 }
1062 "max_length" => {
1063 if let Some(val) = &assertion.value {
1064 if let Some(n) = val.as_u64() {
1065 let len_expr = if result_is_bytes {
1066 format!("{field_expr}.length")
1067 } else {
1068 format!("{field_expr}.length()")
1069 };
1070 let _ = writeln!(
1071 out,
1072 " assertTrue({len_expr} <= {n}, \"expected length <= {n}\");"
1073 );
1074 }
1075 }
1076 }
1077 "count_min" => {
1078 if let Some(val) = &assertion.value {
1079 if let Some(n) = val.as_u64() {
1080 let _ = writeln!(
1081 out,
1082 " assertTrue({field_expr}.size() >= {n}, \"expected at least {n} elements\");"
1083 );
1084 }
1085 }
1086 }
1087 "count_equals" => {
1088 if let Some(val) = &assertion.value {
1089 if let Some(n) = val.as_u64() {
1090 let _ = writeln!(
1091 out,
1092 " assertEquals({n}, {field_expr}.size(), \"expected exactly {n} elements\");"
1093 );
1094 }
1095 }
1096 }
1097 "is_true" => {
1098 let _ = writeln!(out, " assertTrue({field_expr}, \"expected true\");");
1099 }
1100 "is_false" => {
1101 let _ = writeln!(out, " assertFalse({field_expr}, \"expected false\");");
1102 }
1103 "method_result" => {
1104 if let Some(method_name) = &assertion.method {
1105 let call_expr = build_java_method_call(result_var, method_name, assertion.args.as_ref(), class_name);
1106 let check = assertion.check.as_deref().unwrap_or("is_true");
1107 let method_returns_collection =
1109 matches!(method_name.as_str(), "find_nodes_by_type" | "findNodesByType");
1110 match check {
1111 "equals" => {
1112 if let Some(val) = &assertion.value {
1113 if val.is_boolean() {
1114 if val.as_bool() == Some(true) {
1115 let _ = writeln!(out, " assertTrue({call_expr});");
1116 } else {
1117 let _ = writeln!(out, " assertFalse({call_expr});");
1118 }
1119 } else if method_returns_collection {
1120 let java_val = json_to_java(val);
1121 let _ = writeln!(out, " assertEquals({java_val}, {call_expr}.size());");
1122 } else {
1123 let java_val = json_to_java(val);
1124 let _ = writeln!(out, " assertEquals({java_val}, {call_expr});");
1125 }
1126 }
1127 }
1128 "is_true" => {
1129 let _ = writeln!(out, " assertTrue({call_expr});");
1130 }
1131 "is_false" => {
1132 let _ = writeln!(out, " assertFalse({call_expr});");
1133 }
1134 "greater_than_or_equal" => {
1135 if let Some(val) = &assertion.value {
1136 let n = val.as_u64().unwrap_or(0);
1137 let _ = writeln!(out, " assertTrue({call_expr} >= {n}, \"expected >= {n}\");");
1138 }
1139 }
1140 "count_min" => {
1141 if let Some(val) = &assertion.value {
1142 let n = val.as_u64().unwrap_or(0);
1143 let _ = writeln!(
1144 out,
1145 " assertTrue({call_expr}.size() >= {n}, \"expected at least {n} elements\");"
1146 );
1147 }
1148 }
1149 "is_error" => {
1150 let _ = writeln!(out, " assertThrows(Exception.class, () -> {{ {call_expr}; }});");
1151 }
1152 "contains" => {
1153 if let Some(val) = &assertion.value {
1154 let java_val = json_to_java(val);
1155 let _ = writeln!(
1156 out,
1157 " assertTrue({call_expr}.contains({java_val}), \"expected to contain: \" + {java_val});"
1158 );
1159 }
1160 }
1161 other_check => {
1162 panic!("Java e2e generator: unsupported method_result check type: {other_check}");
1163 }
1164 }
1165 } else {
1166 panic!("Java e2e generator: method_result assertion missing 'method' field");
1167 }
1168 }
1169 "matches_regex" => {
1170 if let Some(expected) = &assertion.value {
1171 let java_val = json_to_java(expected);
1172 let _ = writeln!(
1173 out,
1174 " assertTrue({string_expr}.matches({java_val}), \"expected value to match regex: \" + {java_val});"
1175 );
1176 }
1177 }
1178 "not_error" => {
1179 }
1181 "error" => {
1182 }
1184 other => {
1185 panic!("Java e2e generator: unsupported assertion type: {other}");
1186 }
1187 }
1188}
1189
1190fn build_java_method_call(
1194 result_var: &str,
1195 method_name: &str,
1196 args: Option<&serde_json::Value>,
1197 class_name: &str,
1198) -> String {
1199 match method_name {
1200 "root_child_count" => format!("{result_var}.rootNode().childCount()"),
1201 "root_node_type" => format!("{result_var}.rootNode().kind()"),
1202 "named_children_count" => format!("{result_var}.rootNode().namedChildCount()"),
1203 "has_error_nodes" => format!("{class_name}.treeHasErrorNodes({result_var})"),
1204 "error_count" | "tree_error_count" => format!("{class_name}.treeErrorCount({result_var})"),
1205 "tree_to_sexp" => format!("{class_name}.treeToSexp({result_var})"),
1206 "contains_node_type" => {
1207 let node_type = args
1208 .and_then(|a| a.get("node_type"))
1209 .and_then(|v| v.as_str())
1210 .unwrap_or("");
1211 format!("{class_name}.treeContainsNodeType({result_var}, \"{node_type}\")")
1212 }
1213 "find_nodes_by_type" => {
1214 let node_type = args
1215 .and_then(|a| a.get("node_type"))
1216 .and_then(|v| v.as_str())
1217 .unwrap_or("");
1218 format!("{class_name}.findNodesByType({result_var}, \"{node_type}\")")
1219 }
1220 "run_query" => {
1221 let query_source = args
1222 .and_then(|a| a.get("query_source"))
1223 .and_then(|v| v.as_str())
1224 .unwrap_or("");
1225 let language = args
1226 .and_then(|a| a.get("language"))
1227 .and_then(|v| v.as_str())
1228 .unwrap_or("");
1229 let escaped_query = escape_java(query_source);
1230 format!("{class_name}.runQuery({result_var}, \"{language}\", \"{escaped_query}\", source)")
1231 }
1232 _ => {
1233 format!("{result_var}.{}()", method_name.to_lower_camel_case())
1234 }
1235 }
1236}
1237
1238fn json_to_java(value: &serde_json::Value) -> String {
1240 json_to_java_typed(value, None)
1241}
1242
1243fn json_to_java_typed(value: &serde_json::Value, element_type: Option<&str>) -> String {
1246 match value {
1247 serde_json::Value::String(s) => format!("\"{}\"", escape_java(s)),
1248 serde_json::Value::Bool(b) => b.to_string(),
1249 serde_json::Value::Number(n) => {
1250 if n.is_f64() {
1251 match element_type {
1252 Some("f32" | "float" | "Float") => format!("{}f", n),
1253 _ => format!("{}d", n),
1254 }
1255 } else {
1256 n.to_string()
1257 }
1258 }
1259 serde_json::Value::Null => "null".to_string(),
1260 serde_json::Value::Array(arr) => {
1261 let items: Vec<String> = arr.iter().map(|v| json_to_java_typed(v, element_type)).collect();
1262 format!("java.util.List.of({})", items.join(", "))
1263 }
1264 serde_json::Value::Object(_) => {
1265 let json_str = serde_json::to_string(value).unwrap_or_default();
1266 format!("\"{}\"", escape_java(&json_str))
1267 }
1268 }
1269}
1270
1271fn build_java_visitor(
1277 setup_lines: &mut Vec<String>,
1278 visitor_spec: &crate::fixture::VisitorSpec,
1279 class_name: &str,
1280) -> String {
1281 setup_lines.push("class _TestVisitor implements TestVisitor {".to_string());
1282 for (method_name, action) in &visitor_spec.callbacks {
1283 emit_java_visitor_method(setup_lines, method_name, action, class_name);
1284 }
1285 setup_lines.push("}".to_string());
1286 setup_lines.push("var visitor = new _TestVisitor();".to_string());
1287 "visitor".to_string()
1288}
1289
1290fn emit_java_visitor_method(
1292 setup_lines: &mut Vec<String>,
1293 method_name: &str,
1294 action: &CallbackAction,
1295 _class_name: &str,
1296) {
1297 let camel_method = method_to_camel(method_name);
1298 let params = match method_name {
1299 "visit_link" => "VisitContext ctx, String href, String text, String title",
1300 "visit_image" => "VisitContext ctx, String src, String alt, String title",
1301 "visit_heading" => "VisitContext ctx, int level, String text, String id",
1302 "visit_code_block" => "VisitContext ctx, String lang, String code",
1303 "visit_code_inline"
1304 | "visit_strong"
1305 | "visit_emphasis"
1306 | "visit_strikethrough"
1307 | "visit_underline"
1308 | "visit_subscript"
1309 | "visit_superscript"
1310 | "visit_mark"
1311 | "visit_button"
1312 | "visit_summary"
1313 | "visit_figcaption"
1314 | "visit_definition_term"
1315 | "visit_definition_description" => "VisitContext ctx, String text",
1316 "visit_text" => "VisitContext ctx, String text",
1317 "visit_list_item" => "VisitContext ctx, boolean ordered, String marker, String text",
1318 "visit_blockquote" => "VisitContext ctx, String content, int depth",
1319 "visit_table_row" => "VisitContext ctx, java.util.List<String> cells, boolean isHeader",
1320 "visit_custom_element" => "VisitContext ctx, String tagName, String html",
1321 "visit_form" => "VisitContext ctx, String actionUrl, String method",
1322 "visit_input" => "VisitContext ctx, String inputType, String name, String value",
1323 "visit_audio" | "visit_video" | "visit_iframe" => "VisitContext ctx, String src",
1324 "visit_details" => "VisitContext ctx, boolean isOpen",
1325 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
1326 "VisitContext ctx, String output"
1327 }
1328 "visit_list_start" => "VisitContext ctx, boolean ordered",
1329 "visit_list_end" => "VisitContext ctx, boolean ordered, String output",
1330 _ => "VisitContext ctx",
1331 };
1332
1333 setup_lines.push(format!(" @Override public VisitResult {camel_method}({params}) {{"));
1334 match action {
1335 CallbackAction::Skip => {
1336 setup_lines.push(" return VisitResult.skip();".to_string());
1337 }
1338 CallbackAction::Continue => {
1339 setup_lines.push(" return VisitResult.continue_();".to_string());
1340 }
1341 CallbackAction::PreserveHtml => {
1342 setup_lines.push(" return VisitResult.preserveHtml();".to_string());
1343 }
1344 CallbackAction::Custom { output } => {
1345 let escaped = escape_java(output);
1346 setup_lines.push(format!(" return VisitResult.custom(\"{escaped}\");"));
1347 }
1348 CallbackAction::CustomTemplate { template } => {
1349 let escaped = escape_java(template);
1350 setup_lines.push(format!(
1351 " return VisitResult.custom(String.format(\"{escaped}\"));"
1352 ));
1353 }
1354 }
1355 setup_lines.push(" }".to_string());
1356}
1357
1358fn method_to_camel(snake: &str) -> String {
1360 snake.to_lower_camel_case()
1361}