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