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