1use crate::codegen::resolve_field;
7use crate::config::E2eConfig;
8use crate::escape::{ruby_string_literal, ruby_template_to_interpolation, sanitize_filename, sanitize_ident};
9use crate::field_access::FieldResolver;
10use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup, ValidationErrorExpectation};
11use alef_core::backend::GeneratedFile;
12use alef_core::config::ResolvedCrateConfig;
13use alef_core::hash::{self, CommentStyle};
14use alef_core::template_versions as tv;
15use anyhow::Result;
16use heck::ToSnakeCase;
17use std::collections::HashMap;
18use std::path::PathBuf;
19
20use super::E2eCodegen;
21use super::client;
22
23pub struct RubyCodegen;
25
26impl E2eCodegen for RubyCodegen {
27 fn generate(
28 &self,
29 groups: &[FixtureGroup],
30 e2e_config: &E2eConfig,
31 config: &ResolvedCrateConfig,
32 ) -> Result<Vec<GeneratedFile>> {
33 let lang = self.language_name();
34 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
35
36 let mut files = Vec::new();
37
38 let call = &e2e_config.call;
40 let overrides = call.overrides.get(lang);
41 let module_path = overrides
42 .and_then(|o| o.module.as_ref())
43 .cloned()
44 .unwrap_or_else(|| call.module.clone());
45 let class_name = overrides.and_then(|o| o.class.as_ref()).cloned();
46 let options_type = overrides.and_then(|o| o.options_type.clone());
47 let empty_enum_fields = HashMap::new();
48 let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
49 let result_is_simple = call.result_is_simple || overrides.is_some_and(|o| o.result_is_simple);
50
51 let ruby_pkg = e2e_config.resolve_package("ruby");
53 let gem_name = ruby_pkg
54 .as_ref()
55 .and_then(|p| p.name.as_ref())
56 .cloned()
57 .unwrap_or_else(|| config.name.replace('-', "_"));
58 let gem_path = ruby_pkg
59 .as_ref()
60 .and_then(|p| p.path.as_ref())
61 .cloned()
62 .unwrap_or_else(|| "../../packages/ruby".to_string());
63 let gem_version = ruby_pkg
64 .as_ref()
65 .and_then(|p| p.version.as_ref())
66 .cloned()
67 .or_else(|| config.resolved_version())
68 .unwrap_or_else(|| "0.1.0".to_string());
69
70 files.push(GeneratedFile {
72 path: output_base.join("Gemfile"),
73 content: render_gemfile(&gem_name, &gem_path, &gem_version, e2e_config.dep_mode),
74 generated_header: false,
75 });
76
77 files.push(GeneratedFile {
79 path: output_base.join(".rubocop.yaml"),
80 content: render_rubocop_yaml(),
81 generated_header: false,
82 });
83
84 let has_http_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| f.is_http_test());
86
87 let has_file_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| {
89 let cc = e2e_config.resolve_call(f.call.as_deref());
90 cc.args
91 .iter()
92 .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
93 });
94
95 if has_file_fixtures || has_http_fixtures {
97 files.push(GeneratedFile {
98 path: output_base.join("spec").join("spec_helper.rb"),
99 content: render_spec_helper(has_file_fixtures, has_http_fixtures),
100 generated_header: true,
101 });
102 }
103
104 let spec_base = output_base.join("spec");
106
107 for group in groups {
108 let active: Vec<&Fixture> = group
109 .fixtures
110 .iter()
111 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
112 .collect();
113
114 if active.is_empty() {
115 continue;
116 }
117
118 let field_resolver_pre = FieldResolver::new(
119 &e2e_config.fields,
120 &e2e_config.fields_optional,
121 &e2e_config.result_fields,
122 &e2e_config.fields_array,
123 &std::collections::HashSet::new(),
124 );
125 let has_any_output = active.iter().any(|f| {
127 if f.is_http_test() {
129 return true;
130 }
131 let expects_error = f.assertions.iter().any(|a| a.assertion_type == "error");
132 let has_not_error = f.assertions.iter().any(|a| a.assertion_type == "not_error");
133 expects_error || has_not_error || has_usable_assertion(f, &field_resolver_pre, result_is_simple)
134 });
135 if !has_any_output {
136 continue;
137 }
138
139 let filename = format!("{}_spec.rb", sanitize_filename(&group.category));
140 let field_resolver = FieldResolver::new(
141 &e2e_config.fields,
142 &e2e_config.fields_optional,
143 &e2e_config.result_fields,
144 &e2e_config.fields_array,
145 &std::collections::HashSet::new(),
146 );
147 let content = render_spec_file(
148 &group.category,
149 &active,
150 &module_path,
151 class_name.as_deref(),
152 &gem_name,
153 &field_resolver,
154 options_type.as_deref(),
155 enum_fields,
156 result_is_simple,
157 e2e_config,
158 has_file_fixtures || has_http_fixtures,
159 );
160 files.push(GeneratedFile {
161 path: spec_base.join(filename),
162 content,
163 generated_header: true,
164 });
165 }
166
167 Ok(files)
168 }
169
170 fn language_name(&self) -> &'static str {
171 "ruby"
172 }
173}
174
175fn render_gemfile(
180 gem_name: &str,
181 gem_path: &str,
182 gem_version: &str,
183 dep_mode: crate::config::DependencyMode,
184) -> String {
185 let gem_line = match dep_mode {
186 crate::config::DependencyMode::Registry => format!("gem '{gem_name}', '{gem_version}'"),
187 crate::config::DependencyMode::Local => format!("gem '{gem_name}', path: '{gem_path}'"),
188 };
189 crate::template_env::render(
190 "ruby/Gemfile.jinja",
191 minijinja::context! {
192 gem_line => gem_line,
193 rspec => tv::gem::RSPEC_E2E,
194 rubocop => tv::gem::RUBOCOP_E2E,
195 rubocop_rspec => tv::gem::RUBOCOP_RSPEC_E2E,
196 faraday => tv::gem::FARADAY,
197 },
198 )
199}
200
201fn render_spec_helper(has_file_fixtures: bool, has_http_fixtures: bool) -> String {
202 let header = hash::header(CommentStyle::Hash);
203 let mut out = header;
204 out.push_str("# frozen_string_literal: true\n");
205
206 if has_file_fixtures {
207 out.push_str(
208 r#"
209# Change to the test_documents directory so that fixture file paths like
210# "pdf/fake_memo.pdf" resolve correctly when running rspec from e2e/ruby/.
211# spec_helper.rb lives in e2e/ruby/spec/; test_documents lives at the
212# repository root, three directories up: spec/ -> e2e/ruby/ -> e2e/ -> root.
213_test_documents = File.expand_path('../../../test_documents', __dir__)
214Dir.chdir(_test_documents) if Dir.exist?(_test_documents)
215"#,
216 );
217 }
218
219 if has_http_fixtures {
220 out.push_str(
221 r#"
222require 'open3'
223
224# Spawn the mock-server binary and set MOCK_SERVER_URL for all tests.
225RSpec.configure do |config|
226 config.before(:suite) do
227 bin = File.expand_path('../../rust/target/release/mock-server', __dir__)
228 fixtures_dir = File.expand_path('../../../fixtures', __dir__)
229 unless File.exist?(bin)
230 warn "mock-server binary not found at #{bin} — run: cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release"
231 end
232 stdin, stdout, _stderr, _wait = Open3.popen3(bin, fixtures_dir)
233 url = stdout.readline.strip.split('=', 2).last
234 ENV['MOCK_SERVER_URL'] = url
235 # Drain stdout in background.
236 Thread.new { stdout.read }
237 # Store stdin so we can close it on teardown.
238 @_mock_server_stdin = stdin
239 end
240
241 config.after(:suite) do
242 @_mock_server_stdin&.close
243 end
244end
245"#,
246 );
247 }
248
249 out
250}
251
252fn render_rubocop_yaml() -> String {
253 crate::template_env::render("ruby/rubocop.yml.jinja", minijinja::context! {})
254}
255
256#[allow(clippy::too_many_arguments)]
257fn render_spec_file(
258 category: &str,
259 fixtures: &[&Fixture],
260 module_path: &str,
261 class_name: Option<&str>,
262 gem_name: &str,
263 field_resolver: &FieldResolver,
264 options_type: Option<&str>,
265 enum_fields: &HashMap<String, String>,
266 result_is_simple: bool,
267 e2e_config: &E2eConfig,
268 needs_spec_helper: bool,
269) -> String {
270 let client_factory = e2e_config
272 .call
273 .overrides
274 .get("ruby")
275 .and_then(|o| o.client_factory.as_deref());
276
277 let require_name = if module_path.is_empty() { gem_name } else { module_path };
279 let mut requires = vec![require_name.replace('-', "_"), "json".to_string()];
280
281 let has_http = fixtures.iter().any(|f| f.is_http_test());
282 if needs_spec_helper || has_http {
283 requires.push("spec_helper".to_string());
284 }
285
286 let call_receiver = class_name
288 .map(|s| s.to_string())
289 .unwrap_or_else(|| ruby_module_name(module_path));
290
291 let has_array_contains = fixtures.iter().any(|fixture| {
293 fixture.assertions.iter().any(|a| {
294 matches!(a.assertion_type.as_str(), "contains" | "contains_all" | "not_contains")
295 && a.field
296 .as_deref()
297 .is_some_and(|f| !f.is_empty() && field_resolver.is_array(field_resolver.resolve(f)))
298 })
299 });
300
301 let mut examples = Vec::new();
303 for fixture in fixtures {
304 if fixture.http.is_some() {
305 let mut out = String::new();
307 render_http_example(&mut out, fixture);
308 examples.push(out);
309 } else {
310 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
312 let _has_not_error = fixture.assertions.iter().any(|a| a.assertion_type == "not_error");
313 let has_usable = has_usable_assertion(fixture, field_resolver, result_is_simple);
314 if !expects_error && !has_usable {
315 let test_name = sanitize_ident(&fixture.id);
316 let description = fixture.description.replace('\'', "\\'");
317 let mut out = String::new();
318 out.push_str(&format!(" it '{test_name}: {description}' do\n"));
319 out.push_str(" skip 'Non-HTTP fixture cannot be tested via Net::HTTP'\n");
320 out.push_str(" end\n");
321 examples.push(out);
322 } else {
323 let fixture_call = e2e_config.resolve_call(fixture.call.as_deref());
325 let fixture_call_overrides = fixture_call.overrides.get("ruby");
326 let fixture_function_name = fixture_call_overrides
327 .and_then(|o| o.function.as_ref())
328 .cloned()
329 .unwrap_or_else(|| fixture_call.function.clone());
330 let fixture_result_var = &fixture_call.result_var;
331 let fixture_args = &fixture_call.args;
332 let fixture_client_factory = fixture_call_overrides
333 .and_then(|o| o.client_factory.as_deref())
334 .or(client_factory);
335 let fixture_options_type = fixture_call_overrides
336 .and_then(|o| o.options_type.as_deref())
337 .or(options_type);
338
339 let example = render_example(
340 fixture,
341 &fixture_function_name,
342 &call_receiver,
343 fixture_result_var,
344 fixture_args,
345 field_resolver,
346 fixture_options_type,
347 enum_fields,
348 result_is_simple,
349 e2e_config,
350 fixture_client_factory,
351 );
352 examples.push(example);
353 }
354 }
355 }
356
357 let header = hash::header(CommentStyle::Hash);
358 crate::template_env::render(
359 "ruby/test_file.jinja",
360 minijinja::context! {
361 category => category,
362 requires => requires,
363 has_array_contains => has_array_contains,
364 has_http => has_http,
365 examples => examples,
366 header => header,
367 },
368 )
369}
370
371fn has_usable_assertion(fixture: &Fixture, field_resolver: &FieldResolver, result_is_simple: bool) -> bool {
374 fixture.assertions.iter().any(|a| {
375 if a.assertion_type == "not_error" || a.assertion_type == "error" {
377 return false;
378 }
379 if let Some(f) = &a.field {
381 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
382 return false;
383 }
384 if result_is_simple {
386 let f_lower = f.to_lowercase();
387 if !f.is_empty()
388 && f_lower != "content"
389 && (f_lower.starts_with("metadata")
390 || f_lower.starts_with("document")
391 || f_lower.starts_with("structure"))
392 {
393 return false;
394 }
395 }
396 }
397 true
398 })
399}
400
401struct RubyTestClientRenderer;
409
410impl client::TestClientRenderer for RubyTestClientRenderer {
411 fn language_name(&self) -> &'static str {
412 "ruby"
413 }
414
415 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
421 let escaped_description = description.replace('\'', "\\'");
422 let rendered = crate::template_env::render(
423 "ruby/http_test.jinja",
424 minijinja::context! {
425 fn_name => fn_name,
426 description => escaped_description,
427 skip_reason => skip_reason,
428 },
429 );
430 out.push_str(&rendered);
431 }
432
433 fn render_test_close(&self, out: &mut String) {
435 let rendered = crate::template_env::render("ruby/http_test_close.jinja", minijinja::context! {});
436 out.push_str(&rendered);
437 }
438
439 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
441 let method = ctx.method.to_uppercase();
442 let method_class = http_method_class(&method);
443
444 let has_body = ctx
445 .body
446 .is_some_and(|b| !matches!(b, serde_json::Value::String(s) if s.is_empty()));
447
448 let ruby_body = if has_body {
449 json_to_ruby(ctx.body.unwrap())
450 } else {
451 String::new()
452 };
453
454 let headers: Vec<minijinja::Value> = ctx
455 .headers
456 .iter()
457 .filter(|(k, _)| {
458 !(has_body && k.to_lowercase() == "content-type")
460 })
461 .map(|(k, v)| {
462 minijinja::context! {
463 key_literal => ruby_string_literal(k),
464 value_literal => ruby_string_literal(v),
465 }
466 })
467 .collect();
468
469 let rendered = crate::template_env::render(
470 "ruby/http_request.jinja",
471 minijinja::context! {
472 method_class => method_class,
473 path => ctx.path,
474 has_body => has_body,
475 ruby_body => ruby_body,
476 headers => headers,
477 response_var => ctx.response_var,
478 },
479 );
480 out.push_str(&rendered);
481 }
482
483 fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
488 out.push_str(&format!(" expect({response_var}.code.to_i).to eq({status})\n"));
489 }
490
491 fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
495 let header_key = name.to_lowercase();
496 let header_expr = format!("{response_var}[{}]", ruby_string_literal(&header_key));
497 let assertion = match expected {
498 "<<present>>" => {
499 format!(" expect({header_expr}).not_to be_nil\n")
500 }
501 "<<absent>>" => {
502 format!(" expect({header_expr}).to be_nil\n")
503 }
504 "<<uuid>>" => {
505 format!(
506 " expect({header_expr}).to match(/\\A[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}\\z/i)\n"
507 )
508 }
509 literal => {
510 let ruby_val = ruby_string_literal(literal);
511 format!(" expect({header_expr}).to eq({ruby_val})\n")
512 }
513 };
514 out.push_str(&assertion);
515 }
516
517 fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
522 match expected {
523 serde_json::Value::String(s) => {
524 let ruby_val = ruby_string_literal(s);
525 out.push_str(&format!(" expect({response_var}.body).to eq({ruby_val})\n"));
526 }
527 _ => {
528 let ruby_val = json_to_ruby(expected);
529 out.push_str(&format!(
530 " _body = {response_var}.body && !{response_var}.body.empty? ? JSON.parse({response_var}.body) : nil\n"
531 ));
532 out.push_str(&format!(" expect(_body).to eq({ruby_val})\n"));
533 }
534 }
535 }
536
537 fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
539 if let Some(obj) = expected.as_object() {
540 out.push_str(&format!(" _body = JSON.parse({response_var}.body)\n"));
541 for (key, val) in obj {
542 let ruby_key = ruby_string_literal(key);
543 let ruby_val = json_to_ruby(val);
544 out.push_str(&format!(" expect(_body[{ruby_key}]).to eq({ruby_val})\n"));
545 }
546 }
547 }
548
549 fn render_assert_validation_errors(
552 &self,
553 out: &mut String,
554 response_var: &str,
555 errors: &[ValidationErrorExpectation],
556 ) {
557 for err in errors {
558 let msg_lit = ruby_string_literal(&err.msg);
559 out.push_str(&format!(" _body = JSON.parse({response_var}.body)\n"));
560 out.push_str(" _errors = _body['errors'] || []\n");
561 out.push_str(&format!(
562 " expect(_errors.map {{ |e| e['msg'] }}).to include({msg_lit})\n"
563 ));
564 }
565 }
566}
567
568fn render_http_example(out: &mut String, fixture: &Fixture) {
574 if fixture
578 .http
579 .as_ref()
580 .is_some_and(|h| h.expected_response.status_code == 101)
581 {
582 if let Some(http) = fixture.http.as_ref() {
583 let description = fixture.description.replace('\'', "\\'");
584 let method = http.request.method.to_uppercase();
585 let path = &http.request.path;
586 let rendered = crate::template_env::render(
587 "ruby/http_101_skip.jinja",
588 minijinja::context! {
589 method => method,
590 path => path,
591 description => description,
592 },
593 );
594 out.push_str(&rendered);
595 }
596 return;
597 }
598
599 client::http_call::render_http_test(out, &RubyTestClientRenderer, fixture);
600}
601
602fn http_method_class(method: &str) -> String {
605 let mut chars = method.chars();
606 match chars.next() {
607 None => String::new(),
608 Some(first) => first.to_uppercase().collect::<String>() + &chars.as_str().to_lowercase(),
609 }
610}
611
612#[allow(clippy::too_many_arguments)]
617fn render_example(
618 fixture: &Fixture,
619 function_name: &str,
620 call_receiver: &str,
621 result_var: &str,
622 args: &[crate::config::ArgMapping],
623 field_resolver: &FieldResolver,
624 options_type: Option<&str>,
625 enum_fields: &HashMap<String, String>,
626 result_is_simple: bool,
627 e2e_config: &E2eConfig,
628 client_factory: Option<&str>,
629) -> String {
630 let test_name = sanitize_ident(&fixture.id);
631 let description = fixture.description.replace('\'', "\\'");
632 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
633 let fixture_id = fixture.id.clone();
634
635 let (mut setup_lines, args_str) = build_args_and_setup(
636 &fixture.input,
637 args,
638 call_receiver,
639 options_type,
640 enum_fields,
641 result_is_simple,
642 &fixture.id,
643 );
644
645 let mut visitor_arg = String::new();
647 if let Some(visitor_spec) = &fixture.visitor {
648 visitor_arg = build_ruby_visitor(&mut setup_lines, visitor_spec);
649 }
650
651 let final_args = if visitor_arg.is_empty() {
652 args_str
653 } else if args_str.is_empty() {
654 visitor_arg
655 } else {
656 format!("{args_str}, {visitor_arg}")
657 };
658
659 let call_expr = if client_factory.is_some() {
661 format!("client.{function_name}({final_args})")
662 } else {
663 format!("{call_receiver}.{function_name}({final_args})")
664 };
665
666 let has_usable = has_usable_assertion(fixture, field_resolver, result_is_simple);
668
669 let mut assertions_rendered = String::new();
671 for assertion in &fixture.assertions {
672 render_assertion(
673 &mut assertions_rendered,
674 assertion,
675 result_var,
676 field_resolver,
677 result_is_simple,
678 e2e_config,
679 );
680 }
681
682 crate::template_env::render(
683 "ruby/test_function.jinja",
684 minijinja::context! {
685 test_name => test_name,
686 description => description,
687 expects_error => expects_error,
688 setup_lines => setup_lines,
689 call_expr => call_expr,
690 result_var => result_var,
691 assertions_rendered => assertions_rendered,
692 has_usable => has_usable,
693 client_factory => client_factory,
694 fixture_id => fixture_id,
695 call_receiver => call_receiver,
696 },
697 )
698}
699
700fn emit_ruby_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
705 if let Some(items) = arr.as_array() {
706 let item_strs: Vec<String> = items
707 .iter()
708 .filter_map(|item| {
709 if let Some(obj) = item.as_object() {
710 match elem_type {
711 "BatchBytesItem" => {
712 let content = obj.get("content").and_then(|v| v.as_array());
713 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
714 let config = obj.get("config");
715 let content_code = if let Some(arr) = content {
716 let bytes: Vec<String> =
717 arr.iter().filter_map(|v| v.as_u64().map(|n| n.to_string())).collect();
718 format!("[{}]", bytes.join(", "))
720 } else {
721 "[]".to_string()
722 };
723 let config_arg = if let Some(cfg) = config {
724 if cfg.is_null() {
725 "nil".to_string()
726 } else {
727 json_to_ruby(cfg)
728 }
729 } else {
730 "nil".to_string()
731 };
732 Some(format!(
733 "Kreuzberg::{}.new({}, \"{}\", {})",
734 elem_type, content_code, mime_type, config_arg
735 ))
736 }
737 "BatchFileItem" => {
738 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
739 let config = obj.get("config");
740 let config_arg = if let Some(cfg) = config {
741 if cfg.is_null() {
742 "nil".to_string()
743 } else {
744 json_to_ruby(cfg)
745 }
746 } else {
747 "nil".to_string()
748 };
749 Some(format!("Kreuzberg::{}.new(\"{}\", {})", elem_type, path, config_arg))
750 }
751 _ => None,
752 }
753 } else {
754 None
755 }
756 })
757 .collect();
758 format!("[{}]", item_strs.join(", "))
759 } else {
760 "[]".to_string()
761 }
762}
763
764fn build_args_and_setup(
765 input: &serde_json::Value,
766 args: &[crate::config::ArgMapping],
767 call_receiver: &str,
768 options_type: Option<&str>,
769 enum_fields: &HashMap<String, String>,
770 result_is_simple: bool,
771 fixture_id: &str,
772) -> (Vec<String>, String) {
773 if args.is_empty() {
774 let is_empty_input = match input {
778 serde_json::Value::Null => true,
779 serde_json::Value::Object(m) => m.is_empty(),
780 _ => false,
781 };
782 if is_empty_input {
783 return (Vec::new(), String::new());
784 }
785 return (Vec::new(), json_to_ruby(input));
786 }
787
788 let mut setup_lines: Vec<String> = Vec::new();
789 let mut parts: Vec<String> = Vec::new();
790 let mut skipped_optional_count: usize = 0;
793
794 for arg in args {
795 if arg.arg_type == "mock_url" {
796 for _ in 0..skipped_optional_count {
798 parts.push("nil".to_string());
799 }
800 skipped_optional_count = 0;
801 setup_lines.push(format!(
802 "{} = \"#{{ENV.fetch('MOCK_SERVER_URL')}}/fixtures/{fixture_id}\"",
803 arg.name,
804 ));
805 parts.push(arg.name.clone());
806 continue;
807 }
808
809 if arg.arg_type == "bytes" {
811 for _ in 0..skipped_optional_count {
813 parts.push("nil".to_string());
814 }
815 skipped_optional_count = 0;
816 let resolved = resolve_field(input, &arg.field);
817 if let Some(s) = resolved.as_str() {
818 if is_file_path(s) {
819 setup_lines.push(format!("{} = File.read(\"{}\").bytes", arg.name, s));
821 } else if is_base64(s) {
822 setup_lines.push(format!("{} = Base64.decode64(\"{}\").bytes", arg.name, s));
824 } else {
825 let escaped = ruby_string_literal(s);
827 setup_lines.push(format!("{} = {}.b.bytes", arg.name, escaped));
828 }
829 parts.push(arg.name.clone());
830 } else {
831 parts.push("nil".to_string());
832 }
833 continue;
834 }
835
836 if arg.arg_type == "file_path" {
838 for _ in 0..skipped_optional_count {
840 parts.push("nil".to_string());
841 }
842 skipped_optional_count = 0;
843 let resolved = resolve_field(input, &arg.field);
844 if let Some(s) = resolved.as_str() {
845 let escaped = ruby_string_literal(s);
846 parts.push(escaped);
847 } else if arg.optional {
848 skipped_optional_count += 1;
849 continue;
850 } else {
851 parts.push("''".to_string());
852 }
853 continue;
854 }
855
856 if arg.arg_type == "handle" {
857 for _ in 0..skipped_optional_count {
859 parts.push("nil".to_string());
860 }
861 skipped_optional_count = 0;
862 let constructor_name = format!("create_{}", arg.name.to_snake_case());
864 let config_value = resolve_field(input, &arg.field);
865 if config_value.is_null()
866 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
867 {
868 setup_lines.push(format!("{} = {call_receiver}.{constructor_name}(nil)", arg.name,));
869 } else {
870 let literal = json_to_ruby(config_value);
871 let name = &arg.name;
872 setup_lines.push(format!("{name}_config = {literal}"));
873 setup_lines.push(format!(
874 "{} = {call_receiver}.{constructor_name}({name}_config.to_json)",
875 arg.name,
876 name = name,
877 ));
878 }
879 parts.push(arg.name.clone());
880 continue;
881 }
882
883 let resolved = resolve_field(input, &arg.field);
884 let val = if resolved.is_null() { None } else { Some(resolved) };
885 match val {
886 None | Some(serde_json::Value::Null) if arg.optional => {
887 skipped_optional_count += 1;
889 continue;
890 }
891 None | Some(serde_json::Value::Null) => {
892 for _ in 0..skipped_optional_count {
894 parts.push("nil".to_string());
895 }
896 skipped_optional_count = 0;
897 let default_val = match arg.arg_type.as_str() {
898 "string" => "''".to_string(),
899 "int" | "integer" => "0".to_string(),
900 "float" | "number" => "0.0".to_string(),
901 "bool" | "boolean" => "false".to_string(),
902 _ => "nil".to_string(),
903 };
904 parts.push(default_val);
905 }
906 Some(v) => {
907 for _ in 0..skipped_optional_count {
909 parts.push("nil".to_string());
910 }
911 skipped_optional_count = 0;
912 if arg.arg_type == "json_object" && !v.is_null() {
915 if let Some(elem_type) = &arg.element_type {
917 if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && v.is_array() {
918 parts.push(emit_ruby_batch_item_array(v, elem_type));
919 continue;
920 }
921 }
922 if let (Some(opts_type), Some(obj)) = (options_type, v.as_object()) {
924 let kwargs: Vec<String> = obj
925 .iter()
926 .map(|(k, vv)| {
927 let snake_key = k.to_snake_case();
928 let rb_val = if enum_fields.contains_key(k) {
929 if let Some(s) = vv.as_str() {
930 let snake_val = s.to_snake_case();
931 format!("'{snake_val}'")
932 } else {
933 json_to_ruby(vv)
934 }
935 } else {
936 json_to_ruby(vv)
937 };
938 format!("{snake_key}: {rb_val}")
939 })
940 .collect();
941 if result_is_simple {
942 parts.push(format!("{{{}}}", kwargs.join(", ")));
943 } else {
944 parts.push(format!("{opts_type}.new({})", kwargs.join(", ")));
945 }
946 continue;
947 }
948 }
949 parts.push(json_to_ruby(v));
950 }
951 }
952 }
953
954 (setup_lines, parts.join(", "))
955}
956
957fn render_assertion(
958 out: &mut String,
959 assertion: &Assertion,
960 result_var: &str,
961 field_resolver: &FieldResolver,
962 result_is_simple: bool,
963 e2e_config: &E2eConfig,
964) {
965 if let Some(f) = &assertion.field {
968 match f.as_str() {
969 "chunks_have_content" => {
970 let pred = format!("({result_var}.chunks || []).all? {{ |c| c.content && !c.content.empty? }}");
971 match assertion.assertion_type.as_str() {
972 "is_true" => {
973 out.push_str(&format!(" expect({pred}).to be(true)\n"));
974 }
975 "is_false" => {
976 out.push_str(&format!(" expect({pred}).to be(false)\n"));
977 }
978 _ => {
979 out.push_str(&format!(
980 " # skipped: unsupported assertion type on synthetic field '{f}'\n"
981 ));
982 }
983 }
984 return;
985 }
986 "chunks_have_embeddings" => {
987 let pred =
988 format!("({result_var}.chunks || []).all? {{ |c| !c.embedding.nil? && !c.embedding.empty? }}");
989 match assertion.assertion_type.as_str() {
990 "is_true" => {
991 out.push_str(&format!(" expect({pred}).to be(true)\n"));
992 }
993 "is_false" => {
994 out.push_str(&format!(" expect({pred}).to be(false)\n"));
995 }
996 _ => {
997 out.push_str(&format!(
998 " # skipped: unsupported assertion type on synthetic field '{f}'\n"
999 ));
1000 }
1001 }
1002 return;
1003 }
1004 "embeddings" => {
1008 match assertion.assertion_type.as_str() {
1009 "count_equals" => {
1010 if let Some(val) = &assertion.value {
1011 let rb_val = json_to_ruby(val);
1012 out.push_str(&format!(" expect({result_var}.length).to eq({rb_val})\n"));
1013 }
1014 }
1015 "count_min" => {
1016 if let Some(val) = &assertion.value {
1017 let rb_val = json_to_ruby(val);
1018 out.push_str(&format!(" expect({result_var}.length).to be >= {rb_val}\n"));
1019 }
1020 }
1021 "not_empty" => {
1022 out.push_str(&format!(" expect({result_var}).not_to be_empty\n"));
1023 }
1024 "is_empty" => {
1025 out.push_str(&format!(" expect({result_var}).to be_empty\n"));
1026 }
1027 _ => {
1028 out.push_str(" # skipped: unsupported assertion type on synthetic field 'embeddings'\n");
1029 }
1030 }
1031 return;
1032 }
1033 "embedding_dimensions" => {
1034 let expr = format!("({result_var}.empty? ? 0 : {result_var}[0].length)");
1035 match assertion.assertion_type.as_str() {
1036 "equals" => {
1037 if let Some(val) = &assertion.value {
1038 let rb_val = json_to_ruby(val);
1039 out.push_str(&format!(" expect({expr}).to eq({rb_val})\n"));
1040 }
1041 }
1042 "greater_than" => {
1043 if let Some(val) = &assertion.value {
1044 let rb_val = json_to_ruby(val);
1045 out.push_str(&format!(" expect({expr}).to be > {rb_val}\n"));
1046 }
1047 }
1048 _ => {
1049 out.push_str(
1050 " # skipped: unsupported assertion type on synthetic field 'embedding_dimensions'\n",
1051 );
1052 }
1053 }
1054 return;
1055 }
1056 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1057 let pred = match f.as_str() {
1058 "embeddings_valid" => {
1059 format!("{result_var}.all? {{ |e| !e.empty? }}")
1060 }
1061 "embeddings_finite" => {
1062 format!("{result_var}.all? {{ |e| e.all? {{ |v| v.finite? }} }}")
1063 }
1064 "embeddings_non_zero" => {
1065 format!("{result_var}.all? {{ |e| e.any? {{ |v| v != 0.0 }} }}")
1066 }
1067 "embeddings_normalized" => {
1068 format!("{result_var}.all? {{ |e| n = e.sum {{ |v| v * v }}; (n - 1.0).abs < 1e-3 }}")
1069 }
1070 _ => unreachable!(),
1071 };
1072 match assertion.assertion_type.as_str() {
1073 "is_true" => {
1074 out.push_str(&format!(" expect({pred}).to be(true)\n"));
1075 }
1076 "is_false" => {
1077 out.push_str(&format!(" expect({pred}).to be(false)\n"));
1078 }
1079 _ => {
1080 out.push_str(&format!(
1081 " # skipped: unsupported assertion type on synthetic field '{f}'\n"
1082 ));
1083 }
1084 }
1085 return;
1086 }
1087 "keywords" | "keywords_count" => {
1090 out.push_str(&format!(
1091 " # skipped: field '{f}' not available on Ruby ExtractionResult\n"
1092 ));
1093 return;
1094 }
1095 _ => {}
1096 }
1097 }
1098
1099 if let Some(f) = &assertion.field {
1101 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1102 out.push_str(&format!(" # skipped: field '{f}' not available on result type\n"));
1103 return;
1104 }
1105 }
1106
1107 if result_is_simple {
1109 if let Some(f) = &assertion.field {
1110 let f_lower = f.to_lowercase();
1111 if !f.is_empty()
1112 && f_lower != "content"
1113 && (f_lower.starts_with("metadata")
1114 || f_lower.starts_with("document")
1115 || f_lower.starts_with("structure"))
1116 {
1117 return;
1118 }
1119 }
1120 }
1121
1122 let field_expr = match &assertion.field {
1126 Some(f) if !f.is_empty() && (!result_is_simple || !f.eq_ignore_ascii_case("content")) => {
1127 field_resolver.accessor(f, "ruby", result_var)
1128 }
1129 _ => result_var.to_string(),
1130 };
1131
1132 let stripped_field_expr = if result_is_simple {
1135 format!("{field_expr}.to_s.strip")
1136 } else {
1137 field_expr.clone()
1138 };
1139
1140 let field_is_array = assertion
1143 .field
1144 .as_deref()
1145 .filter(|f| !f.is_empty())
1146 .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
1147
1148 match assertion.assertion_type.as_str() {
1149 "equals" => {
1150 if let Some(expected) = &assertion.value {
1151 let is_boolean_val = expected.as_bool().is_some();
1152 let bool_val = expected
1153 .as_bool()
1154 .map(|b| if b { "true" } else { "false" })
1155 .unwrap_or("");
1156 let rb_val = json_to_ruby(expected);
1157
1158 let rendered = crate::template_env::render(
1159 "ruby/assertion.jinja",
1160 minijinja::context! {
1161 assertion_type => "equals",
1162 stripped_field_expr => stripped_field_expr.clone(),
1163 is_boolean_val => is_boolean_val,
1164 bool_val => bool_val,
1165 expected_val => rb_val,
1166 },
1167 );
1168 out.push_str(&rendered);
1169 }
1170 }
1171 "contains" => {
1172 if let Some(expected) = &assertion.value {
1173 let rb_val = json_to_ruby(expected);
1174 let rendered = crate::template_env::render(
1175 "ruby/assertion.jinja",
1176 minijinja::context! {
1177 assertion_type => "contains",
1178 field_expr => field_expr.clone(),
1179 field_is_array => field_is_array && expected.is_string(),
1180 expected_val => rb_val,
1181 },
1182 );
1183 out.push_str(&rendered);
1184 }
1185 }
1186 "contains_all" => {
1187 if let Some(values) = &assertion.values {
1188 let values_list: Vec<String> = values.iter().map(json_to_ruby).collect();
1189 let rendered = crate::template_env::render(
1190 "ruby/assertion.jinja",
1191 minijinja::context! {
1192 assertion_type => "contains_all",
1193 field_expr => field_expr.clone(),
1194 field_is_array => field_is_array,
1195 values_list => values_list,
1196 },
1197 );
1198 out.push_str(&rendered);
1199 }
1200 }
1201 "not_contains" => {
1202 if let Some(expected) = &assertion.value {
1203 let rb_val = json_to_ruby(expected);
1204 let rendered = crate::template_env::render(
1205 "ruby/assertion.jinja",
1206 minijinja::context! {
1207 assertion_type => "not_contains",
1208 field_expr => field_expr.clone(),
1209 field_is_array => field_is_array && expected.is_string(),
1210 expected_val => rb_val,
1211 },
1212 );
1213 out.push_str(&rendered);
1214 }
1215 }
1216 "not_empty" => {
1217 let rendered = crate::template_env::render(
1218 "ruby/assertion.jinja",
1219 minijinja::context! {
1220 assertion_type => "not_empty",
1221 field_expr => field_expr.clone(),
1222 },
1223 );
1224 out.push_str(&rendered);
1225 }
1226 "is_empty" => {
1227 let rendered = crate::template_env::render(
1228 "ruby/assertion.jinja",
1229 minijinja::context! {
1230 assertion_type => "is_empty",
1231 field_expr => field_expr.clone(),
1232 },
1233 );
1234 out.push_str(&rendered);
1235 }
1236 "contains_any" => {
1237 if let Some(values) = &assertion.values {
1238 let items: Vec<String> = values.iter().map(json_to_ruby).collect();
1239 let rendered = crate::template_env::render(
1240 "ruby/assertion.jinja",
1241 minijinja::context! {
1242 assertion_type => "contains_any",
1243 field_expr => field_expr.clone(),
1244 values_list => items,
1245 },
1246 );
1247 out.push_str(&rendered);
1248 }
1249 }
1250 "greater_than" => {
1251 if let Some(val) = &assertion.value {
1252 let rb_val = json_to_ruby(val);
1253 let rendered = crate::template_env::render(
1254 "ruby/assertion.jinja",
1255 minijinja::context! {
1256 assertion_type => "greater_than",
1257 field_expr => field_expr.clone(),
1258 expected_val => rb_val,
1259 },
1260 );
1261 out.push_str(&rendered);
1262 }
1263 }
1264 "less_than" => {
1265 if let Some(val) = &assertion.value {
1266 let rb_val = json_to_ruby(val);
1267 let rendered = crate::template_env::render(
1268 "ruby/assertion.jinja",
1269 minijinja::context! {
1270 assertion_type => "less_than",
1271 field_expr => field_expr.clone(),
1272 expected_val => rb_val,
1273 },
1274 );
1275 out.push_str(&rendered);
1276 }
1277 }
1278 "greater_than_or_equal" => {
1279 if let Some(val) = &assertion.value {
1280 let rb_val = json_to_ruby(val);
1281 let rendered = crate::template_env::render(
1282 "ruby/assertion.jinja",
1283 minijinja::context! {
1284 assertion_type => "greater_than_or_equal",
1285 field_expr => field_expr.clone(),
1286 expected_val => rb_val,
1287 },
1288 );
1289 out.push_str(&rendered);
1290 }
1291 }
1292 "less_than_or_equal" => {
1293 if let Some(val) = &assertion.value {
1294 let rb_val = json_to_ruby(val);
1295 let rendered = crate::template_env::render(
1296 "ruby/assertion.jinja",
1297 minijinja::context! {
1298 assertion_type => "less_than_or_equal",
1299 field_expr => field_expr.clone(),
1300 expected_val => rb_val,
1301 },
1302 );
1303 out.push_str(&rendered);
1304 }
1305 }
1306 "starts_with" => {
1307 if let Some(expected) = &assertion.value {
1308 let rb_val = json_to_ruby(expected);
1309 let rendered = crate::template_env::render(
1310 "ruby/assertion.jinja",
1311 minijinja::context! {
1312 assertion_type => "starts_with",
1313 field_expr => field_expr.clone(),
1314 expected_val => rb_val,
1315 },
1316 );
1317 out.push_str(&rendered);
1318 }
1319 }
1320 "ends_with" => {
1321 if let Some(expected) = &assertion.value {
1322 let rb_val = json_to_ruby(expected);
1323 let rendered = crate::template_env::render(
1324 "ruby/assertion.jinja",
1325 minijinja::context! {
1326 assertion_type => "ends_with",
1327 field_expr => field_expr.clone(),
1328 expected_val => rb_val,
1329 },
1330 );
1331 out.push_str(&rendered);
1332 }
1333 }
1334 "min_length" | "max_length" | "count_min" | "count_equals" => {
1335 if let Some(val) = &assertion.value {
1336 if let Some(n) = val.as_u64() {
1337 let rendered = crate::template_env::render(
1338 "ruby/assertion.jinja",
1339 minijinja::context! {
1340 assertion_type => assertion.assertion_type.as_str(),
1341 field_expr => field_expr.clone(),
1342 check_n => n,
1343 },
1344 );
1345 out.push_str(&rendered);
1346 }
1347 }
1348 }
1349 "is_true" => {
1350 let rendered = crate::template_env::render(
1351 "ruby/assertion.jinja",
1352 minijinja::context! {
1353 assertion_type => "is_true",
1354 field_expr => field_expr.clone(),
1355 },
1356 );
1357 out.push_str(&rendered);
1358 }
1359 "is_false" => {
1360 let rendered = crate::template_env::render(
1361 "ruby/assertion.jinja",
1362 minijinja::context! {
1363 assertion_type => "is_false",
1364 field_expr => field_expr.clone(),
1365 },
1366 );
1367 out.push_str(&rendered);
1368 }
1369 "method_result" => {
1370 if let Some(method_name) = &assertion.method {
1371 let lang = "ruby";
1373 let call = &e2e_config.call;
1374 let overrides = call.overrides.get(lang);
1375 let module_path = overrides
1376 .and_then(|o| o.module.as_ref())
1377 .cloned()
1378 .unwrap_or_else(|| call.module.clone());
1379 let call_receiver = ruby_module_name(&module_path);
1380
1381 let call_expr =
1382 build_ruby_method_call(&call_receiver, result_var, method_name, assertion.args.as_ref());
1383 let check = assertion.check.as_deref().unwrap_or("is_true");
1384
1385 let (check_val_str, is_boolean_check, bool_check_val, check_n_val) = match check {
1386 "equals" => {
1387 if let Some(val) = &assertion.value {
1388 let is_bool = val.as_bool().is_some();
1389 let bool_str = val.as_bool().map(|b| if b { "true" } else { "false" }).unwrap_or("");
1390 let rb_val = json_to_ruby(val);
1391 (rb_val, is_bool, bool_str.to_string(), 0)
1392 } else {
1393 (String::new(), false, String::new(), 0)
1394 }
1395 }
1396 "greater_than_or_equal" => {
1397 if let Some(val) = &assertion.value {
1398 (json_to_ruby(val), false, String::new(), 0)
1399 } else {
1400 (String::new(), false, String::new(), 0)
1401 }
1402 }
1403 "count_min" => {
1404 if let Some(val) = &assertion.value {
1405 let n = val.as_u64().unwrap_or(0);
1406 (String::new(), false, String::new(), n)
1407 } else {
1408 (String::new(), false, String::new(), 0)
1409 }
1410 }
1411 "contains" => {
1412 if let Some(val) = &assertion.value {
1413 (json_to_ruby(val), false, String::new(), 0)
1414 } else {
1415 (String::new(), false, String::new(), 0)
1416 }
1417 }
1418 _ => (String::new(), false, String::new(), 0),
1419 };
1420
1421 let rendered = crate::template_env::render(
1422 "ruby/assertion.jinja",
1423 minijinja::context! {
1424 assertion_type => "method_result",
1425 call_expr => call_expr,
1426 check => check,
1427 check_val => check_val_str,
1428 is_boolean_check => is_boolean_check,
1429 bool_check_val => bool_check_val,
1430 check_n => check_n_val,
1431 },
1432 );
1433 out.push_str(&rendered);
1434 } else {
1435 panic!("Ruby e2e generator: method_result assertion missing 'method' field");
1436 }
1437 }
1438 "matches_regex" => {
1439 if let Some(expected) = &assertion.value {
1440 let rb_val = json_to_ruby(expected);
1441 let rendered = crate::template_env::render(
1442 "ruby/assertion.jinja",
1443 minijinja::context! {
1444 assertion_type => "matches_regex",
1445 field_expr => field_expr.clone(),
1446 expected_val => rb_val,
1447 },
1448 );
1449 out.push_str(&rendered);
1450 }
1451 }
1452 "not_error" => {
1453 }
1455 "error" => {
1456 }
1458 other => {
1459 panic!("Ruby e2e generator: unsupported assertion type: {other}");
1460 }
1461 }
1462}
1463
1464fn build_ruby_method_call(
1467 call_receiver: &str,
1468 result_var: &str,
1469 method_name: &str,
1470 args: Option<&serde_json::Value>,
1471) -> String {
1472 match method_name {
1473 "root_child_count" => format!("{result_var}.root_node.child_count"),
1474 "root_node_type" => format!("{result_var}.root_node.type"),
1475 "named_children_count" => format!("{result_var}.root_node.named_child_count"),
1476 "has_error_nodes" => format!("{call_receiver}.tree_has_error_nodes({result_var})"),
1477 "error_count" | "tree_error_count" => format!("{call_receiver}.tree_error_count({result_var})"),
1478 "tree_to_sexp" => format!("{call_receiver}.tree_to_sexp({result_var})"),
1479 "contains_node_type" => {
1480 let node_type = args
1481 .and_then(|a| a.get("node_type"))
1482 .and_then(|v| v.as_str())
1483 .unwrap_or("");
1484 format!("{call_receiver}.tree_contains_node_type({result_var}, \"{node_type}\")")
1485 }
1486 "find_nodes_by_type" => {
1487 let node_type = args
1488 .and_then(|a| a.get("node_type"))
1489 .and_then(|v| v.as_str())
1490 .unwrap_or("");
1491 format!("{call_receiver}.find_nodes_by_type({result_var}, \"{node_type}\")")
1492 }
1493 "run_query" => {
1494 let query_source = args
1495 .and_then(|a| a.get("query_source"))
1496 .and_then(|v| v.as_str())
1497 .unwrap_or("");
1498 let language = args
1499 .and_then(|a| a.get("language"))
1500 .and_then(|v| v.as_str())
1501 .unwrap_or("");
1502 format!("{call_receiver}.run_query({result_var}, \"{language}\", \"{query_source}\", source)")
1503 }
1504 _ => format!("{result_var}.{method_name}"),
1505 }
1506}
1507
1508fn ruby_module_name(module_path: &str) -> String {
1511 use heck::ToUpperCamelCase;
1512 module_path.to_upper_camel_case()
1513}
1514
1515fn json_to_ruby(value: &serde_json::Value) -> String {
1517 match value {
1518 serde_json::Value::String(s) => ruby_string_literal(s),
1519 serde_json::Value::Bool(true) => "true".to_string(),
1520 serde_json::Value::Bool(false) => "false".to_string(),
1521 serde_json::Value::Number(n) => n.to_string(),
1522 serde_json::Value::Null => "nil".to_string(),
1523 serde_json::Value::Array(arr) => {
1524 let items: Vec<String> = arr.iter().map(json_to_ruby).collect();
1525 format!("[{}]", items.join(", "))
1526 }
1527 serde_json::Value::Object(map) => {
1528 let items: Vec<String> = map
1529 .iter()
1530 .map(|(k, v)| format!("{} => {}", ruby_string_literal(k), json_to_ruby(v)))
1531 .collect();
1532 format!("{{ {} }}", items.join(", "))
1533 }
1534 }
1535}
1536
1537fn build_ruby_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1543 setup_lines.push("visitor = Class.new do".to_string());
1544 for (method_name, action) in &visitor_spec.callbacks {
1545 emit_ruby_visitor_method(setup_lines, method_name, action);
1546 }
1547 setup_lines.push("end.new".to_string());
1548 "visitor".to_string()
1549}
1550
1551fn emit_ruby_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
1553 let params = match method_name {
1554 "visit_link" => "ctx, href, text, title",
1555 "visit_image" => "ctx, src, alt, title",
1556 "visit_heading" => "ctx, level, text, id",
1557 "visit_code_block" => "ctx, lang, code",
1558 "visit_code_inline"
1559 | "visit_strong"
1560 | "visit_emphasis"
1561 | "visit_strikethrough"
1562 | "visit_underline"
1563 | "visit_subscript"
1564 | "visit_superscript"
1565 | "visit_mark"
1566 | "visit_button"
1567 | "visit_summary"
1568 | "visit_figcaption"
1569 | "visit_definition_term"
1570 | "visit_definition_description" => "ctx, text",
1571 "visit_text" => "ctx, text",
1572 "visit_list_item" => "ctx, ordered, marker, text",
1573 "visit_blockquote" => "ctx, content, depth",
1574 "visit_table_row" => "ctx, cells, is_header",
1575 "visit_custom_element" => "ctx, tag_name, html",
1576 "visit_form" => "ctx, action_url, method",
1577 "visit_input" => "ctx, input_type, name, value",
1578 "visit_audio" | "visit_video" | "visit_iframe" => "ctx, src",
1579 "visit_details" => "ctx, is_open",
1580 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "ctx, output",
1581 "visit_list_start" => "ctx, ordered",
1582 "visit_list_end" => "ctx, ordered, output",
1583 _ => "ctx",
1584 };
1585
1586 let (action_type, action_value) = match action {
1588 CallbackAction::Skip => ("skip", String::new()),
1589 CallbackAction::Continue => ("continue", String::new()),
1590 CallbackAction::PreserveHtml => ("preserve_html", String::new()),
1591 CallbackAction::Custom { output } => {
1592 let escaped = ruby_string_literal(output);
1593 ("custom", escaped)
1594 }
1595 CallbackAction::CustomTemplate { template } => {
1596 let interpolated = ruby_template_to_interpolation(template);
1597 ("custom", format!("\"{interpolated}\""))
1598 }
1599 };
1600
1601 let rendered = crate::template_env::render(
1602 "ruby/visitor_method.jinja",
1603 minijinja::context! {
1604 method_name => method_name,
1605 params => params,
1606 action_type => action_type,
1607 action_value => action_value,
1608 },
1609 );
1610 for line in rendered.lines() {
1611 setup_lines.push(line.to_string());
1612 }
1613}
1614
1615fn is_file_path(s: &str) -> bool {
1620 if s.starts_with('<') || s.starts_with('{') || s.starts_with('[') || s.contains(' ') {
1621 return false;
1622 }
1623
1624 let first = s.chars().next().unwrap_or('\0');
1625 if first.is_ascii_alphanumeric() || first == '_' {
1626 if let Some(slash_pos) = s.find('/') {
1627 if slash_pos > 0 {
1628 let after_slash = &s[slash_pos + 1..];
1629 if after_slash.contains('.') && !after_slash.is_empty() {
1630 return true;
1631 }
1632 }
1633 }
1634 }
1635
1636 false
1637}
1638
1639fn is_base64(s: &str) -> bool {
1642 if s.starts_with('<') || s.starts_with('{') || s.starts_with('[') || s.contains(' ') {
1643 return false;
1644 }
1645
1646 if is_file_path(s) {
1647 return false;
1648 }
1649
1650 true
1651}