1use crate::config::E2eConfig;
7use crate::escape::{ruby_string_literal, sanitize_filename, sanitize_ident};
8use crate::field_access::FieldResolver;
9use crate::fixture::{
10 Assertion, CallbackAction, Fixture, FixtureGroup, HttpExpectedResponse, HttpFixture, HttpRequest,
11};
12use alef_core::backend::GeneratedFile;
13use alef_core::config::AlefConfig;
14use alef_core::hash::{self, CommentStyle};
15use alef_core::template_versions as tv;
16use anyhow::Result;
17use heck::ToSnakeCase;
18use std::collections::HashMap;
19use std::fmt::Write as FmtWrite;
20use std::path::PathBuf;
21
22use super::E2eCodegen;
23
24pub struct RubyCodegen;
26
27impl E2eCodegen for RubyCodegen {
28 fn generate(
29 &self,
30 groups: &[FixtureGroup],
31 e2e_config: &E2eConfig,
32 alef_config: &AlefConfig,
33 ) -> Result<Vec<GeneratedFile>> {
34 let lang = self.language_name();
35 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
36
37 let mut files = Vec::new();
38
39 let call = &e2e_config.call;
41 let overrides = call.overrides.get(lang);
42 let module_path = overrides
43 .and_then(|o| o.module.as_ref())
44 .cloned()
45 .unwrap_or_else(|| call.module.clone());
46 let class_name = overrides.and_then(|o| o.class.as_ref()).cloned();
47 let options_type = overrides.and_then(|o| o.options_type.clone());
48 let empty_enum_fields = HashMap::new();
49 let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
50 let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
51
52 let ruby_pkg = e2e_config.resolve_package("ruby");
54 let gem_name = ruby_pkg
55 .as_ref()
56 .and_then(|p| p.name.as_ref())
57 .cloned()
58 .unwrap_or_else(|| alef_config.crate_config.name.replace('-', "_"));
59 let gem_path = ruby_pkg
60 .as_ref()
61 .and_then(|p| p.path.as_ref())
62 .cloned()
63 .unwrap_or_else(|| "../../packages/ruby".to_string());
64 let gem_version = ruby_pkg
65 .as_ref()
66 .and_then(|p| p.version.as_ref())
67 .cloned()
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 spec_base = output_base.join("spec");
86
87 for group in groups {
88 let active: Vec<&Fixture> = group
89 .fixtures
90 .iter()
91 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
92 .collect();
93
94 if active.is_empty() {
95 continue;
96 }
97
98 let field_resolver_pre = FieldResolver::new(
99 &e2e_config.fields,
100 &e2e_config.fields_optional,
101 &e2e_config.result_fields,
102 &e2e_config.fields_array,
103 );
104 let has_any_output = active.iter().any(|f| {
106 if f.is_http_test() {
108 return true;
109 }
110 let expects_error = f.assertions.iter().any(|a| a.assertion_type == "error");
111 expects_error || has_usable_assertion(f, &field_resolver_pre, result_is_simple)
112 });
113 if !has_any_output {
114 continue;
115 }
116
117 let filename = format!("{}_spec.rb", sanitize_filename(&group.category));
118 let field_resolver = FieldResolver::new(
119 &e2e_config.fields,
120 &e2e_config.fields_optional,
121 &e2e_config.result_fields,
122 &e2e_config.fields_array,
123 );
124 let content = render_spec_file(
125 &group.category,
126 &active,
127 &module_path,
128 class_name.as_deref(),
129 &gem_name,
130 &field_resolver,
131 options_type.as_deref(),
132 enum_fields,
133 result_is_simple,
134 e2e_config,
135 );
136 files.push(GeneratedFile {
137 path: spec_base.join(filename),
138 content,
139 generated_header: true,
140 });
141 }
142
143 Ok(files)
144 }
145
146 fn language_name(&self) -> &'static str {
147 "ruby"
148 }
149}
150
151fn render_gemfile(
156 gem_name: &str,
157 gem_path: &str,
158 gem_version: &str,
159 dep_mode: crate::config::DependencyMode,
160) -> String {
161 let gem_line = match dep_mode {
162 crate::config::DependencyMode::Registry => format!("gem '{gem_name}', '{gem_version}'"),
163 crate::config::DependencyMode::Local => format!("gem '{gem_name}', path: '{gem_path}'"),
164 };
165 format!(
166 "# frozen_string_literal: true\n\
167 \n\
168 source 'https://rubygems.org'\n\
169 \n\
170 {gem_line}\n\
171 gem 'rspec', '{rspec}'\n\
172 gem 'rubocop', '{rubocop}'\n\
173 gem 'rubocop-rspec', '{rubocop_rspec}'\n\
174 gem 'faraday', '{faraday}'\n",
175 rspec = tv::gem::RSPEC_E2E,
176 rubocop = tv::gem::RUBOCOP_E2E,
177 rubocop_rspec = tv::gem::RUBOCOP_RSPEC_E2E,
178 faraday = tv::gem::FARADAY,
179 )
180}
181
182fn render_rubocop_yaml() -> String {
183 r#"# Generated by alef e2e — do not edit.
184AllCops:
185 NewCops: enable
186 TargetRubyVersion: 3.2
187 SuggestExtensions: false
188
189plugins:
190 - rubocop-rspec
191
192# --- Justified suppressions for generated test code ---
193
194# Generated tests are verbose by nature (setup + multiple assertions).
195Metrics/BlockLength:
196 Enabled: false
197Metrics/MethodLength:
198 Enabled: false
199Layout/LineLength:
200 Enabled: false
201
202# Generated tests use multiple assertions per example for thorough verification.
203RSpec/MultipleExpectations:
204 Enabled: false
205RSpec/ExampleLength:
206 Enabled: false
207
208# Generated tests describe categories as strings, not classes.
209RSpec/DescribeClass:
210 Enabled: false
211
212# Fixture-driven tests may produce identical assertion bodies for different inputs.
213RSpec/RepeatedExample:
214 Enabled: false
215
216# Error-handling tests use bare raise_error (exception type not known at generation time).
217RSpec/UnspecifiedException:
218 Enabled: false
219"#
220 .to_string()
221}
222
223#[allow(clippy::too_many_arguments)]
224fn render_spec_file(
225 category: &str,
226 fixtures: &[&Fixture],
227 module_path: &str,
228 class_name: Option<&str>,
229 gem_name: &str,
230 field_resolver: &FieldResolver,
231 options_type: Option<&str>,
232 enum_fields: &HashMap<String, String>,
233 result_is_simple: bool,
234 e2e_config: &E2eConfig,
235) -> String {
236 let mut out = String::new();
237 out.push_str(&hash::header(CommentStyle::Hash));
238 let _ = writeln!(out, "# frozen_string_literal: true");
239 let _ = writeln!(out);
240
241 let require_name = if module_path.is_empty() { gem_name } else { module_path };
243 let _ = writeln!(out, "require '{}'", require_name.replace('-', "_"));
244 let _ = writeln!(out, "require 'json'");
245
246 let has_http = fixtures.iter().any(|f| f.is_http_test());
248 if has_http {
249 let _ = writeln!(out, "require 'faraday'");
250 }
251 let _ = writeln!(out);
252
253 let call_receiver = class_name
255 .map(|s| s.to_string())
256 .unwrap_or_else(|| ruby_module_name(module_path));
257
258 let _ = writeln!(out, "RSpec.describe '{}' do", category);
259
260 if has_http {
262 let _ = writeln!(
263 out,
264 " let(:base_url) {{ ENV.fetch('TEST_SERVER_URL', 'http://localhost:8080') }}"
265 );
266 let _ = writeln!(out, " let(:client) do");
267 let _ = writeln!(out, " Faraday.new(url: base_url) do |f|");
268 let _ = writeln!(out, " f.request :json");
269 let _ = writeln!(out, " f.response :json, content_type: /\\bjson$/");
270 let _ = writeln!(out, " end");
271 let _ = writeln!(out, " end");
272 let _ = writeln!(out);
273 }
274
275 let mut first = true;
276 for fixture in fixtures {
277 if !first {
278 let _ = writeln!(out);
279 }
280 first = false;
281
282 if let Some(http) = &fixture.http {
283 render_http_example(&mut out, fixture, http);
284 } else {
285 let fixture_call = e2e_config.resolve_call(fixture.call.as_deref());
287 let fixture_call_overrides = fixture_call.overrides.get("ruby");
288 let fixture_function_name = fixture_call_overrides
289 .and_then(|o| o.function.as_ref())
290 .cloned()
291 .unwrap_or_else(|| fixture_call.function.clone());
292 let fixture_result_var = &fixture_call.result_var;
293 let fixture_args = &fixture_call.args;
294 render_example(
295 &mut out,
296 fixture,
297 &fixture_function_name,
298 &call_receiver,
299 fixture_result_var,
300 fixture_args,
301 field_resolver,
302 options_type,
303 enum_fields,
304 result_is_simple,
305 e2e_config,
306 );
307 }
308 }
309
310 let _ = writeln!(out, "end");
311 out
312}
313
314fn has_usable_assertion(fixture: &Fixture, field_resolver: &FieldResolver, result_is_simple: bool) -> bool {
317 fixture.assertions.iter().any(|a| {
318 if a.assertion_type == "not_error" || a.assertion_type == "error" {
320 return false;
321 }
322 if let Some(f) = &a.field {
324 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
325 return false;
326 }
327 if result_is_simple {
329 let f_lower = f.to_lowercase();
330 if !f.is_empty()
331 && f_lower != "content"
332 && (f_lower.starts_with("metadata")
333 || f_lower.starts_with("document")
334 || f_lower.starts_with("structure"))
335 {
336 return false;
337 }
338 }
339 }
340 true
341 })
342}
343
344fn render_http_example(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
350 let description = fixture.description.replace('\'', "\\'");
351 let method = http.request.method.to_uppercase();
352 let path = &http.request.path;
353
354 let _ = writeln!(out, " describe '{method} {path}' do");
355 let _ = writeln!(out, " it '{}' do", description);
356
357 render_ruby_http_request(out, &http.request);
359
360 let status = http.expected_response.status_code;
362 let _ = writeln!(out, " expect(response.status).to eq({status})");
363
364 render_ruby_body_assertions(out, &http.expected_response);
366
367 render_ruby_header_assertions(out, &http.expected_response);
369
370 let _ = writeln!(out, " end");
371 let _ = writeln!(out, " end");
372}
373
374fn render_ruby_http_request(out: &mut String, req: &HttpRequest) {
376 let method = req.method.to_lowercase();
377
378 let mut opts: Vec<String> = Vec::new();
380
381 if let Some(body) = &req.body {
382 let ruby_body = json_to_ruby(body);
383 opts.push(format!("json: {ruby_body}"));
384 }
385
386 if !req.headers.is_empty() {
387 let header_pairs: Vec<String> = req
388 .headers
389 .iter()
390 .map(|(k, v)| format!("{} => {}", ruby_string_literal(k), ruby_string_literal(v)))
391 .collect();
392 opts.push(format!("headers: {{ {} }}", header_pairs.join(", ")));
393 }
394
395 if !req.cookies.is_empty() {
396 let cookie_str = req
397 .cookies
398 .iter()
399 .map(|(k, v)| format!("{}={}", k, v))
400 .collect::<Vec<_>>()
401 .join("; ");
402 opts.push(format!(
403 "headers: {{ 'Cookie' => {} }}",
404 ruby_string_literal(&cookie_str)
405 ));
406 }
407
408 let path = if req.query_params.is_empty() {
410 ruby_string_literal(&req.path)
411 } else {
412 let pairs: Vec<String> = req
413 .query_params
414 .iter()
415 .map(|(k, v)| {
416 let val_str = match v {
417 serde_json::Value::String(s) => s.clone(),
418 other => other.to_string(),
419 };
420 format!("{}={}", k, val_str)
421 })
422 .collect();
423 ruby_string_literal(&format!("{}?{}", req.path, pairs.join("&")))
424 };
425
426 if opts.is_empty() {
427 let _ = writeln!(out, " response = client.{method}({path})");
428 } else {
429 let _ = writeln!(out, " response = client.{method}({path},");
430 for (i, opt) in opts.iter().enumerate() {
431 if i + 1 < opts.len() {
432 let _ = writeln!(out, " {opt},");
433 } else {
434 let _ = writeln!(out, " {opt}");
435 }
436 }
437 let _ = writeln!(out, " )");
438 }
439}
440
441fn render_ruby_body_assertions(out: &mut String, expected: &HttpExpectedResponse) {
443 if let Some(body) = &expected.body {
444 let ruby_val = json_to_ruby(body);
445 let _ = writeln!(out, " expect(response.body).to eq({ruby_val})");
446 }
447 if let Some(partial) = &expected.body_partial {
448 if let Some(obj) = partial.as_object() {
449 for (key, val) in obj {
450 let ruby_key = ruby_string_literal(key);
451 let ruby_val = json_to_ruby(val);
452 let _ = writeln!(out, " expect(response.body[{ruby_key}]).to eq({ruby_val})");
453 }
454 }
455 }
456 if let Some(errors) = &expected.validation_errors {
457 for err in errors {
458 let msg_lit = ruby_string_literal(&err.msg);
459 let _ = writeln!(out, " expect(response.body.to_s).to include({msg_lit})");
460 }
461 }
462}
463
464fn render_ruby_header_assertions(out: &mut String, expected: &HttpExpectedResponse) {
471 for (name, value) in &expected.headers {
472 let header_key = name.to_lowercase();
473 let header_expr = format!("response.headers[{}]", ruby_string_literal(&header_key));
474 match value.as_str() {
475 "<<present>>" => {
476 let _ = writeln!(out, " expect({header_expr}).not_to be_nil");
477 }
478 "<<absent>>" => {
479 let _ = writeln!(out, " expect({header_expr}).to be_nil");
480 }
481 "<<uuid>>" => {
482 let _ = writeln!(
483 out,
484 " 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)"
485 );
486 }
487 literal => {
488 let ruby_val = ruby_string_literal(literal);
489 let _ = writeln!(out, " expect({header_expr}).to eq({ruby_val})");
490 }
491 }
492 }
493}
494
495#[allow(clippy::too_many_arguments)]
500fn render_example(
501 out: &mut String,
502 fixture: &Fixture,
503 function_name: &str,
504 call_receiver: &str,
505 result_var: &str,
506 args: &[crate::config::ArgMapping],
507 field_resolver: &FieldResolver,
508 options_type: Option<&str>,
509 enum_fields: &HashMap<String, String>,
510 result_is_simple: bool,
511 e2e_config: &E2eConfig,
512) {
513 let test_name = sanitize_ident(&fixture.id);
514 let description = fixture.description.replace('\'', "\\'");
515 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
516
517 let (mut setup_lines, args_str) = build_args_and_setup(
518 &fixture.input,
519 args,
520 call_receiver,
521 options_type,
522 enum_fields,
523 result_is_simple,
524 &fixture.id,
525 );
526
527 let mut visitor_arg = String::new();
529 if let Some(visitor_spec) = &fixture.visitor {
530 visitor_arg = build_ruby_visitor(&mut setup_lines, visitor_spec);
531 }
532
533 let final_args = if visitor_arg.is_empty() {
534 args_str
535 } else if args_str.is_empty() {
536 visitor_arg
537 } else {
538 format!("{args_str}, {visitor_arg}")
539 };
540
541 let call_expr = format!("{call_receiver}.{function_name}({final_args})");
542
543 let _ = writeln!(out, " it '{test_name}: {description}' do");
544
545 for line in &setup_lines {
546 let _ = writeln!(out, " {line}");
547 }
548
549 if expects_error {
550 let _ = writeln!(out, " expect {{ {call_expr} }}.to raise_error");
551 let _ = writeln!(out, " end");
552 return;
553 }
554
555 let has_usable = has_usable_assertion(fixture, field_resolver, result_is_simple);
557 let _ = writeln!(out, " {result_var} = {call_expr}");
558
559 for assertion in &fixture.assertions {
560 render_assertion(out, assertion, result_var, field_resolver, result_is_simple, e2e_config);
561 }
562
563 if !has_usable {
567 let _ = writeln!(out, " expect({result_var}).not_to be_nil");
568 }
569
570 let _ = writeln!(out, " end");
571}
572
573fn build_args_and_setup(
577 input: &serde_json::Value,
578 args: &[crate::config::ArgMapping],
579 call_receiver: &str,
580 options_type: Option<&str>,
581 enum_fields: &HashMap<String, String>,
582 result_is_simple: bool,
583 fixture_id: &str,
584) -> (Vec<String>, String) {
585 if args.is_empty() {
586 return (Vec::new(), json_to_ruby(input));
587 }
588
589 let mut setup_lines: Vec<String> = Vec::new();
590 let mut parts: Vec<String> = Vec::new();
591
592 for arg in args {
593 if arg.arg_type == "mock_url" {
594 setup_lines.push(format!(
595 "{} = \"#{{ENV.fetch('MOCK_SERVER_URL')}}/fixtures/{fixture_id}\"",
596 arg.name,
597 ));
598 parts.push(arg.name.clone());
599 continue;
600 }
601
602 if arg.arg_type == "handle" {
603 let constructor_name = format!("create_{}", arg.name.to_snake_case());
605 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
606 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
607 if config_value.is_null()
608 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
609 {
610 setup_lines.push(format!("{} = {call_receiver}.{constructor_name}(nil)", arg.name,));
611 } else {
612 let literal = json_to_ruby(config_value);
613 let name = &arg.name;
614 setup_lines.push(format!("{name}_config = {literal}"));
615 setup_lines.push(format!(
616 "{} = {call_receiver}.{constructor_name}({name}_config.to_json)",
617 arg.name,
618 name = name,
619 ));
620 }
621 parts.push(arg.name.clone());
622 continue;
623 }
624
625 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
626 let val = input.get(field);
627 match val {
628 None | Some(serde_json::Value::Null) if arg.optional => {
629 continue;
631 }
632 None | Some(serde_json::Value::Null) => {
633 let default_val = match arg.arg_type.as_str() {
635 "string" => "''".to_string(),
636 "int" | "integer" => "0".to_string(),
637 "float" | "number" => "0.0".to_string(),
638 "bool" | "boolean" => "false".to_string(),
639 _ => "nil".to_string(),
640 };
641 parts.push(default_val);
642 }
643 Some(v) => {
644 if arg.arg_type == "json_object" && !v.is_null() {
647 if let (Some(opts_type), Some(obj)) = (options_type, v.as_object()) {
648 let kwargs: Vec<String> = obj
649 .iter()
650 .map(|(k, vv)| {
651 let snake_key = k.to_snake_case();
652 let rb_val = if enum_fields.contains_key(k) {
653 if let Some(s) = vv.as_str() {
654 let snake_val = s.to_snake_case();
655 format!("'{snake_val}'")
656 } else {
657 json_to_ruby(vv)
658 }
659 } else {
660 json_to_ruby(vv)
661 };
662 format!("{snake_key}: {rb_val}")
663 })
664 .collect();
665 if result_is_simple {
666 parts.push(format!("{{{}}}", kwargs.join(", ")));
667 } else {
668 parts.push(format!("{opts_type}.new({})", kwargs.join(", ")));
669 }
670 continue;
671 }
672 }
673 parts.push(json_to_ruby(v));
674 }
675 }
676 }
677
678 (setup_lines, parts.join(", "))
679}
680
681fn render_assertion(
682 out: &mut String,
683 assertion: &Assertion,
684 result_var: &str,
685 field_resolver: &FieldResolver,
686 result_is_simple: bool,
687 e2e_config: &E2eConfig,
688) {
689 if let Some(f) = &assertion.field {
691 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
692 let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
693 return;
694 }
695 }
696
697 if result_is_simple {
699 if let Some(f) = &assertion.field {
700 let f_lower = f.to_lowercase();
701 if !f.is_empty()
702 && f_lower != "content"
703 && (f_lower.starts_with("metadata")
704 || f_lower.starts_with("document")
705 || f_lower.starts_with("structure"))
706 {
707 return;
708 }
709 }
710 }
711
712 let field_expr = if result_is_simple {
713 result_var.to_string()
714 } else {
715 match &assertion.field {
716 Some(f) if !f.is_empty() => field_resolver.accessor(f, "ruby", result_var),
717 _ => result_var.to_string(),
718 }
719 };
720
721 let stripped_field_expr = if result_is_simple {
724 format!("{field_expr}.strip")
725 } else {
726 field_expr.clone()
727 };
728
729 match assertion.assertion_type.as_str() {
730 "equals" => {
731 if let Some(expected) = &assertion.value {
732 if let Some(b) = expected.as_bool() {
734 let _ = writeln!(out, " expect({stripped_field_expr}).to be({b})");
735 } else {
736 let rb_val = json_to_ruby(expected);
737 let _ = writeln!(out, " expect({stripped_field_expr}).to eq({rb_val})");
738 }
739 }
740 }
741 "contains" => {
742 if let Some(expected) = &assertion.value {
743 let rb_val = json_to_ruby(expected);
744 let _ = writeln!(out, " expect({field_expr}.to_s).to include({rb_val})");
746 }
747 }
748 "contains_all" => {
749 if let Some(values) = &assertion.values {
750 for val in values {
751 let rb_val = json_to_ruby(val);
752 let _ = writeln!(out, " expect({field_expr}.to_s).to include({rb_val})");
753 }
754 }
755 }
756 "not_contains" => {
757 if let Some(expected) = &assertion.value {
758 let rb_val = json_to_ruby(expected);
759 let _ = writeln!(out, " expect({field_expr}.to_s).not_to include({rb_val})");
760 }
761 }
762 "not_empty" => {
763 let _ = writeln!(out, " expect({field_expr}).not_to be_empty");
764 }
765 "is_empty" => {
766 let _ = writeln!(out, " expect({field_expr}.nil? || {field_expr}.empty?).to be(true)");
768 }
769 "contains_any" => {
770 if let Some(values) = &assertion.values {
771 let items: Vec<String> = values.iter().map(json_to_ruby).collect();
772 let arr_str = items.join(", ");
773 let _ = writeln!(
774 out,
775 " expect([{arr_str}].any? {{ |v| {field_expr}.to_s.include?(v) }}).to be(true)"
776 );
777 }
778 }
779 "greater_than" => {
780 if let Some(val) = &assertion.value {
781 let rb_val = json_to_ruby(val);
782 let _ = writeln!(out, " expect({field_expr}).to be > {rb_val}");
783 }
784 }
785 "less_than" => {
786 if let Some(val) = &assertion.value {
787 let rb_val = json_to_ruby(val);
788 let _ = writeln!(out, " expect({field_expr}).to be < {rb_val}");
789 }
790 }
791 "greater_than_or_equal" => {
792 if let Some(val) = &assertion.value {
793 let rb_val = json_to_ruby(val);
794 let _ = writeln!(out, " expect({field_expr}).to be >= {rb_val}");
795 }
796 }
797 "less_than_or_equal" => {
798 if let Some(val) = &assertion.value {
799 let rb_val = json_to_ruby(val);
800 let _ = writeln!(out, " expect({field_expr}).to be <= {rb_val}");
801 }
802 }
803 "starts_with" => {
804 if let Some(expected) = &assertion.value {
805 let rb_val = json_to_ruby(expected);
806 let _ = writeln!(out, " expect({field_expr}).to start_with({rb_val})");
807 }
808 }
809 "ends_with" => {
810 if let Some(expected) = &assertion.value {
811 let rb_val = json_to_ruby(expected);
812 let _ = writeln!(out, " expect({field_expr}).to end_with({rb_val})");
813 }
814 }
815 "min_length" => {
816 if let Some(val) = &assertion.value {
817 if let Some(n) = val.as_u64() {
818 let _ = writeln!(out, " expect({field_expr}.length).to be >= {n}");
819 }
820 }
821 }
822 "max_length" => {
823 if let Some(val) = &assertion.value {
824 if let Some(n) = val.as_u64() {
825 let _ = writeln!(out, " expect({field_expr}.length).to be <= {n}");
826 }
827 }
828 }
829 "count_min" => {
830 if let Some(val) = &assertion.value {
831 if let Some(n) = val.as_u64() {
832 let _ = writeln!(out, " expect({field_expr}.length).to be >= {n}");
833 }
834 }
835 }
836 "count_equals" => {
837 if let Some(val) = &assertion.value {
838 if let Some(n) = val.as_u64() {
839 let _ = writeln!(out, " expect({field_expr}.length).to eq({n})");
840 }
841 }
842 }
843 "is_true" => {
844 let _ = writeln!(out, " expect({field_expr}).to be true");
845 }
846 "is_false" => {
847 let _ = writeln!(out, " expect({field_expr}).to be false");
848 }
849 "method_result" => {
850 if let Some(method_name) = &assertion.method {
851 let lang = "ruby";
853 let call = &e2e_config.call;
854 let overrides = call.overrides.get(lang);
855 let module_path = overrides
856 .and_then(|o| o.module.as_ref())
857 .cloned()
858 .unwrap_or_else(|| call.module.clone());
859 let call_receiver = ruby_module_name(&module_path);
860
861 let call_expr =
862 build_ruby_method_call(&call_receiver, result_var, method_name, assertion.args.as_ref());
863 let check = assertion.check.as_deref().unwrap_or("is_true");
864 match check {
865 "equals" => {
866 if let Some(val) = &assertion.value {
867 if let Some(b) = val.as_bool() {
868 let _ = writeln!(out, " expect({call_expr}).to be {b}");
869 } else {
870 let rb_val = json_to_ruby(val);
871 let _ = writeln!(out, " expect({call_expr}).to eq({rb_val})");
872 }
873 }
874 }
875 "is_true" => {
876 let _ = writeln!(out, " expect({call_expr}).to be true");
877 }
878 "is_false" => {
879 let _ = writeln!(out, " expect({call_expr}).to be false");
880 }
881 "greater_than_or_equal" => {
882 if let Some(val) = &assertion.value {
883 let rb_val = json_to_ruby(val);
884 let _ = writeln!(out, " expect({call_expr}).to be >= {rb_val}");
885 }
886 }
887 "count_min" => {
888 if let Some(val) = &assertion.value {
889 let n = val.as_u64().unwrap_or(0);
890 let _ = writeln!(out, " expect({call_expr}.length).to be >= {n}");
891 }
892 }
893 "is_error" => {
894 let _ = writeln!(out, " expect {{ {call_expr} }}.to raise_error");
895 }
896 "contains" => {
897 if let Some(val) = &assertion.value {
898 let rb_val = json_to_ruby(val);
899 let _ = writeln!(out, " expect({call_expr}).to include({rb_val})");
900 }
901 }
902 other_check => {
903 panic!("Ruby e2e generator: unsupported method_result check type: {other_check}");
904 }
905 }
906 } else {
907 panic!("Ruby e2e generator: method_result assertion missing 'method' field");
908 }
909 }
910 "matches_regex" => {
911 if let Some(expected) = &assertion.value {
912 let rb_val = json_to_ruby(expected);
913 let _ = writeln!(out, " expect({field_expr}).to match({rb_val})");
914 }
915 }
916 "not_error" => {
917 }
919 "error" => {
920 }
922 other => {
923 panic!("Ruby e2e generator: unsupported assertion type: {other}");
924 }
925 }
926}
927
928fn build_ruby_method_call(
931 call_receiver: &str,
932 result_var: &str,
933 method_name: &str,
934 args: Option<&serde_json::Value>,
935) -> String {
936 match method_name {
937 "root_child_count" => format!("{result_var}.root_node.child_count"),
938 "root_node_type" => format!("{result_var}.root_node.type"),
939 "named_children_count" => format!("{result_var}.root_node.named_child_count"),
940 "has_error_nodes" => format!("{call_receiver}.tree_has_error_nodes({result_var})"),
941 "error_count" | "tree_error_count" => format!("{call_receiver}.tree_error_count({result_var})"),
942 "tree_to_sexp" => format!("{call_receiver}.tree_to_sexp({result_var})"),
943 "contains_node_type" => {
944 let node_type = args
945 .and_then(|a| a.get("node_type"))
946 .and_then(|v| v.as_str())
947 .unwrap_or("");
948 format!("{call_receiver}.tree_contains_node_type({result_var}, \"{node_type}\")")
949 }
950 "find_nodes_by_type" => {
951 let node_type = args
952 .and_then(|a| a.get("node_type"))
953 .and_then(|v| v.as_str())
954 .unwrap_or("");
955 format!("{call_receiver}.find_nodes_by_type({result_var}, \"{node_type}\")")
956 }
957 "run_query" => {
958 let query_source = args
959 .and_then(|a| a.get("query_source"))
960 .and_then(|v| v.as_str())
961 .unwrap_or("");
962 let language = args
963 .and_then(|a| a.get("language"))
964 .and_then(|v| v.as_str())
965 .unwrap_or("");
966 format!("{call_receiver}.run_query({result_var}, \"{language}\", \"{query_source}\", source)")
967 }
968 _ => format!("{result_var}.{method_name}"),
969 }
970}
971
972fn ruby_module_name(module_path: &str) -> String {
975 use heck::ToUpperCamelCase;
976 module_path.to_upper_camel_case()
977}
978
979fn json_to_ruby(value: &serde_json::Value) -> String {
981 match value {
982 serde_json::Value::String(s) => ruby_string_literal(s),
983 serde_json::Value::Bool(true) => "true".to_string(),
984 serde_json::Value::Bool(false) => "false".to_string(),
985 serde_json::Value::Number(n) => n.to_string(),
986 serde_json::Value::Null => "nil".to_string(),
987 serde_json::Value::Array(arr) => {
988 let items: Vec<String> = arr.iter().map(json_to_ruby).collect();
989 format!("[{}]", items.join(", "))
990 }
991 serde_json::Value::Object(map) => {
992 let items: Vec<String> = map
993 .iter()
994 .map(|(k, v)| format!("{} => {}", ruby_string_literal(k), json_to_ruby(v)))
995 .collect();
996 format!("{{ {} }}", items.join(", "))
997 }
998 }
999}
1000
1001fn build_ruby_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1007 setup_lines.push("visitor = Class.new do".to_string());
1008 for (method_name, action) in &visitor_spec.callbacks {
1009 emit_ruby_visitor_method(setup_lines, method_name, action);
1010 }
1011 setup_lines.push("end.new".to_string());
1012 "visitor".to_string()
1013}
1014
1015fn emit_ruby_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
1017 let snake_method = method_name;
1018 let params = match method_name {
1019 "visit_link" => "ctx, href, text, title",
1020 "visit_image" => "ctx, src, alt, title",
1021 "visit_heading" => "ctx, level, text, id",
1022 "visit_code_block" => "ctx, lang, code",
1023 "visit_code_inline"
1024 | "visit_strong"
1025 | "visit_emphasis"
1026 | "visit_strikethrough"
1027 | "visit_underline"
1028 | "visit_subscript"
1029 | "visit_superscript"
1030 | "visit_mark"
1031 | "visit_button"
1032 | "visit_summary"
1033 | "visit_figcaption"
1034 | "visit_definition_term"
1035 | "visit_definition_description" => "ctx, text",
1036 "visit_text" => "ctx, text",
1037 "visit_list_item" => "ctx, ordered, marker, text",
1038 "visit_blockquote" => "ctx, content, depth",
1039 "visit_table_row" => "ctx, cells, is_header",
1040 "visit_custom_element" => "ctx, tag_name, html",
1041 "visit_form" => "ctx, action_url, method",
1042 "visit_input" => "ctx, input_type, name, value",
1043 "visit_audio" | "visit_video" | "visit_iframe" => "ctx, src",
1044 "visit_details" => "ctx, is_open",
1045 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "ctx, output",
1046 "visit_list_start" => "ctx, ordered",
1047 "visit_list_end" => "ctx, ordered, output",
1048 _ => "ctx",
1049 };
1050
1051 setup_lines.push(format!(" def {snake_method}({params})"));
1052 match action {
1053 CallbackAction::Skip => {
1054 setup_lines.push(" 'skip'".to_string());
1055 }
1056 CallbackAction::Continue => {
1057 setup_lines.push(" 'continue'".to_string());
1058 }
1059 CallbackAction::PreserveHtml => {
1060 setup_lines.push(" 'preserve_html'".to_string());
1061 }
1062 CallbackAction::Custom { output } => {
1063 let escaped = ruby_string_literal(output);
1064 setup_lines.push(format!(" {{ custom: {escaped} }}"));
1065 }
1066 CallbackAction::CustomTemplate { template } => {
1067 let escaped = ruby_string_literal(template);
1068 setup_lines.push(format!(" {{ custom: {escaped} }}"));
1069 }
1070 }
1071 setup_lines.push(" end".to_string());
1072}