Skip to main content

alef_e2e/codegen/
ruby.rs

1//! Ruby e2e test generator using RSpec.
2//!
3//! Generates `e2e/ruby/Gemfile` and `spec/{category}_spec.rb` files from
4//! JSON fixtures, driven entirely by `E2eConfig` and `CallConfig`.
5
6use 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
24/// Ruby e2e code generator.
25pub 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        // Resolve call config with overrides.
40        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        // Resolve package config.
53        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        // Generate Gemfile.
71        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        // Generate .rubocop.yaml for linting generated specs.
78        files.push(GeneratedFile {
79            path: output_base.join(".rubocop.yaml"),
80            content: render_rubocop_yaml(),
81            generated_header: false,
82        });
83
84        // Check if any fixture is an HTTP test (needs mock server bootstrap).
85        let has_http_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| f.is_http_test());
86
87        // Generate spec/spec_helper.rb when HTTP fixtures are present.
88        if has_http_fixtures {
89            files.push(GeneratedFile {
90                path: output_base.join("spec").join("spec_helper.rb"),
91                content: render_spec_helper(),
92                generated_header: true,
93            });
94        }
95
96        // Generate spec files per category.
97        let spec_base = output_base.join("spec");
98
99        for group in groups {
100            let active: Vec<&Fixture> = group
101                .fixtures
102                .iter()
103                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
104                .collect();
105
106            if active.is_empty() {
107                continue;
108            }
109
110            let field_resolver_pre = FieldResolver::new(
111                &e2e_config.fields,
112                &e2e_config.fields_optional,
113                &e2e_config.result_fields,
114                &e2e_config.fields_array,
115            );
116            // Skip the entire file if no fixture in this category produces output.
117            let has_any_output = active.iter().any(|f| {
118                // HTTP tests always produce output.
119                if f.is_http_test() {
120                    return true;
121                }
122                let expects_error = f.assertions.iter().any(|a| a.assertion_type == "error");
123                expects_error || has_usable_assertion(f, &field_resolver_pre, result_is_simple)
124            });
125            if !has_any_output {
126                continue;
127            }
128
129            let filename = format!("{}_spec.rb", sanitize_filename(&group.category));
130            let field_resolver = FieldResolver::new(
131                &e2e_config.fields,
132                &e2e_config.fields_optional,
133                &e2e_config.result_fields,
134                &e2e_config.fields_array,
135            );
136            let content = render_spec_file(
137                &group.category,
138                &active,
139                &module_path,
140                class_name.as_deref(),
141                &gem_name,
142                &field_resolver,
143                options_type.as_deref(),
144                enum_fields,
145                result_is_simple,
146                e2e_config,
147            );
148            files.push(GeneratedFile {
149                path: spec_base.join(filename),
150                content,
151                generated_header: true,
152            });
153        }
154
155        Ok(files)
156    }
157
158    fn language_name(&self) -> &'static str {
159        "ruby"
160    }
161}
162
163// ---------------------------------------------------------------------------
164// Rendering
165// ---------------------------------------------------------------------------
166
167fn render_gemfile(
168    gem_name: &str,
169    gem_path: &str,
170    gem_version: &str,
171    dep_mode: crate::config::DependencyMode,
172) -> String {
173    let gem_line = match dep_mode {
174        crate::config::DependencyMode::Registry => format!("gem '{gem_name}', '{gem_version}'"),
175        crate::config::DependencyMode::Local => format!("gem '{gem_name}', path: '{gem_path}'"),
176    };
177    format!(
178        "# frozen_string_literal: true\n\
179         \n\
180         source 'https://rubygems.org'\n\
181         \n\
182         {gem_line}\n\
183         gem 'rspec', '{rspec}'\n\
184         gem 'rubocop', '{rubocop}'\n\
185         gem 'rubocop-rspec', '{rubocop_rspec}'\n\
186         gem 'faraday', '{faraday}'\n",
187        rspec = tv::gem::RSPEC_E2E,
188        rubocop = tv::gem::RUBOCOP_E2E,
189        rubocop_rspec = tv::gem::RUBOCOP_RSPEC_E2E,
190        faraday = tv::gem::FARADAY,
191    )
192}
193
194fn render_spec_helper() -> String {
195    let header = hash::header(CommentStyle::Hash);
196    header
197        + r#"# frozen_string_literal: true
198
199require 'open3'
200
201# Spawn the mock-server binary and set MOCK_SERVER_URL for all tests.
202RSpec.configure do |config|
203  config.before(:suite) do
204    bin = File.expand_path('../../rust/target/release/mock-server', __dir__)
205    fixtures_dir = File.expand_path('../../../fixtures', __dir__)
206    unless File.exist?(bin)
207      warn "mock-server binary not found at #{bin} — run: cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release"
208    end
209    stdin, stdout, _stderr, _wait = Open3.popen3(bin, fixtures_dir)
210    url = stdout.readline.strip.split('=', 2).last
211    ENV['MOCK_SERVER_URL'] = url
212    # Drain stdout in background.
213    Thread.new { stdout.read }
214    # Store stdin so we can close it on teardown.
215    @_mock_server_stdin = stdin
216  end
217
218  config.after(:suite) do
219    @_mock_server_stdin&.close
220  end
221end
222"#
223}
224
225fn render_rubocop_yaml() -> String {
226    r#"# Generated by alef e2e — do not edit.
227AllCops:
228  NewCops: enable
229  TargetRubyVersion: 3.2
230  SuggestExtensions: false
231
232plugins:
233  - rubocop-rspec
234
235# --- Justified suppressions for generated test code ---
236
237# Generated tests are verbose by nature (setup + multiple assertions).
238Metrics/BlockLength:
239  Enabled: false
240Metrics/MethodLength:
241  Enabled: false
242Layout/LineLength:
243  Enabled: false
244
245# Generated tests use multiple assertions per example for thorough verification.
246RSpec/MultipleExpectations:
247  Enabled: false
248RSpec/ExampleLength:
249  Enabled: false
250
251# Generated tests describe categories as strings, not classes.
252RSpec/DescribeClass:
253  Enabled: false
254
255# Fixture-driven tests may produce identical assertion bodies for different inputs.
256RSpec/RepeatedExample:
257  Enabled: false
258
259# Error-handling tests use bare raise_error (exception type not known at generation time).
260RSpec/UnspecifiedException:
261  Enabled: false
262"#
263    .to_string()
264}
265
266#[allow(clippy::too_many_arguments)]
267fn render_spec_file(
268    category: &str,
269    fixtures: &[&Fixture],
270    module_path: &str,
271    class_name: Option<&str>,
272    gem_name: &str,
273    field_resolver: &FieldResolver,
274    options_type: Option<&str>,
275    enum_fields: &HashMap<String, String>,
276    result_is_simple: bool,
277    e2e_config: &E2eConfig,
278) -> String {
279    let mut out = String::new();
280    out.push_str(&hash::header(CommentStyle::Hash));
281    let _ = writeln!(out, "# frozen_string_literal: true");
282    let _ = writeln!(out);
283
284    // Require the gem (single quotes).
285    let require_name = if module_path.is_empty() { gem_name } else { module_path };
286    let _ = writeln!(out, "require '{}'", require_name.replace('-', "_"));
287    let _ = writeln!(out, "require 'json'");
288
289    let has_http = fixtures.iter().any(|f| f.is_http_test());
290    if has_http {
291        // spec_helper sets up the mock server and MOCK_SERVER_URL env var.
292        let _ = writeln!(out, "require_relative 'spec_helper'");
293    }
294    let _ = writeln!(out);
295
296    // Build the Ruby module/class qualifier for calls.
297    let call_receiver = class_name
298        .map(|s| s.to_string())
299        .unwrap_or_else(|| ruby_module_name(module_path));
300
301    let _ = writeln!(out, "RSpec.describe '{}' do", category);
302
303    // Emit a shared client helper when there are HTTP tests.
304    if has_http {
305        let _ = writeln!(
306            out,
307            "  let(:mock_server_url) {{ ENV.fetch('MOCK_SERVER_URL', 'http://localhost:8080') }}"
308        );
309        let _ = writeln!(out);
310    }
311
312    let mut first = true;
313    for fixture in fixtures {
314        if !first {
315            let _ = writeln!(out);
316        }
317        first = false;
318
319        if let Some(http) = &fixture.http {
320            render_http_example(&mut out, fixture, http);
321        } else {
322            // Resolve per-fixture call config (supports named calls via fixture.call field).
323            let fixture_call = e2e_config.resolve_call(fixture.call.as_deref());
324            let fixture_call_overrides = fixture_call.overrides.get("ruby");
325            let fixture_function_name = fixture_call_overrides
326                .and_then(|o| o.function.as_ref())
327                .cloned()
328                .unwrap_or_else(|| fixture_call.function.clone());
329            let fixture_result_var = &fixture_call.result_var;
330            let fixture_args = &fixture_call.args;
331            render_example(
332                &mut out,
333                fixture,
334                &fixture_function_name,
335                &call_receiver,
336                fixture_result_var,
337                fixture_args,
338                field_resolver,
339                options_type,
340                enum_fields,
341                result_is_simple,
342                e2e_config,
343            );
344        }
345    }
346
347    let _ = writeln!(out, "end");
348    out
349}
350
351/// Check if a fixture has at least one assertion that will produce an executable
352/// expect() call (not just a skip comment).
353fn has_usable_assertion(fixture: &Fixture, field_resolver: &FieldResolver, result_is_simple: bool) -> bool {
354    fixture.assertions.iter().any(|a| {
355        // not_error is implicit (call succeeding), error is handled separately.
356        if a.assertion_type == "not_error" || a.assertion_type == "error" {
357            return false;
358        }
359        // Check field validity.
360        if let Some(f) = &a.field {
361            if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
362                return false;
363            }
364            // When result_is_simple, skip non-content fields.
365            if result_is_simple {
366                let f_lower = f.to_lowercase();
367                if !f.is_empty()
368                    && f_lower != "content"
369                    && (f_lower.starts_with("metadata")
370                        || f_lower.starts_with("document")
371                        || f_lower.starts_with("structure"))
372                {
373                    return false;
374                }
375            }
376        }
377        true
378    })
379}
380
381// ---------------------------------------------------------------------------
382// HTTP test rendering
383// ---------------------------------------------------------------------------
384
385/// Render an RSpec `describe` + `it` block for an HTTP server test fixture.
386fn render_http_example(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
387    let description = fixture.description.replace('\'', "\\'");
388    let method = http.request.method.to_uppercase();
389    let path = &http.request.path;
390    let fixture_id = &fixture.id;
391
392    let _ = writeln!(out, "  describe '{method} {path}' do");
393    let _ = writeln!(out, "    it '{}' do", description);
394
395    // Build request call targeting the mock server.
396    render_ruby_http_request_mock(out, &http.request, fixture_id);
397
398    // Assert status (Net::HTTP: response.code is a string, convert to int).
399    let status = http.expected_response.status_code;
400    let _ = writeln!(out, "      expect(response.code.to_i).to eq({status})");
401
402    // Assert response body.
403    render_ruby_body_assertions(out, &http.expected_response);
404
405    // Assert response headers.
406    render_ruby_header_assertions(out, &http.expected_response);
407
408    let _ = writeln!(out, "    end");
409    let _ = writeln!(out, "  end");
410}
411
412/// Emit a Net::HTTP request to the mock server's `/fixtures/<id>` endpoint.
413fn render_ruby_http_request_mock(out: &mut String, req: &HttpRequest, fixture_id: &str) {
414    let method = req.method.to_uppercase();
415    let _ = writeln!(out, "      require 'net/http'");
416    let _ = writeln!(out, "      require 'uri'");
417    let _ = writeln!(out, "      require 'json'");
418    let _ = writeln!(
419        out,
420        "      _uri = URI.parse(\"#{{mock_server_url}}/fixtures/{fixture_id}\")"
421    );
422    let _ = writeln!(out, "      _http = Net::HTTP.new(_uri.host, _uri.port)");
423    let _ = writeln!(out, "      _req = Net::HTTP::{}  .new(_uri.request_uri)", method);
424
425    if let Some(body) = &req.body {
426        let ruby_body = json_to_ruby(body);
427        let _ = writeln!(out, "      _req.body = {ruby_body}.to_json");
428        let _ = writeln!(out, "      _req['Content-Type'] = 'application/json'");
429    }
430
431    for (k, v) in &req.headers {
432        let rk = ruby_string_literal(k);
433        let rv = ruby_string_literal(v);
434        let _ = writeln!(out, "      _req[{rk}] = {rv}");
435    }
436
437    let _ = writeln!(out, "      response = _http.request(_req)");
438}
439
440/// Emit body assertions for an HTTP expected response.
441fn render_ruby_body_assertions(out: &mut String, expected: &HttpExpectedResponse) {
442    if let Some(body) = &expected.body {
443        let ruby_val = json_to_ruby(body);
444        let _ = writeln!(out, "      _body = JSON.parse(response.body)");
445        let _ = writeln!(out, "      expect(_body).to eq({ruby_val})");
446    }
447    if let Some(partial) = &expected.body_partial {
448        if let Some(obj) = partial.as_object() {
449            let _ = writeln!(out, "      _body = JSON.parse(response.body)");
450            for (key, val) in obj {
451                let ruby_key = ruby_string_literal(key);
452                let ruby_val = json_to_ruby(val);
453                let _ = writeln!(out, "      expect(_body[{ruby_key}]).to eq({ruby_val})");
454            }
455        }
456    }
457    if let Some(errors) = &expected.validation_errors {
458        for err in errors {
459            let msg_lit = ruby_string_literal(&err.msg);
460            let _ = writeln!(out, "      expect(response.body.to_s).to include({msg_lit})");
461        }
462    }
463}
464
465/// Emit header assertions for an HTTP expected response.
466///
467/// Special tokens:
468/// - `"<<present>>"` — assert the header key exists
469/// - `"<<absent>>"` — assert the header key is absent
470/// - `"<<uuid>>"` — assert the header value matches a UUID regex
471fn render_ruby_header_assertions(out: &mut String, expected: &HttpExpectedResponse) {
472    for (name, value) in &expected.headers {
473        let header_key = name.to_lowercase();
474        // Net::HTTP response headers are accessed via response[key]
475        let header_expr = format!("response[{}]", ruby_string_literal(&header_key));
476        match value.as_str() {
477            "<<present>>" => {
478                let _ = writeln!(out, "      expect({header_expr}).not_to be_nil");
479            }
480            "<<absent>>" => {
481                let _ = writeln!(out, "      expect({header_expr}).to be_nil");
482            }
483            "<<uuid>>" => {
484                let _ = writeln!(
485                    out,
486                    "      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)"
487                );
488            }
489            literal => {
490                let ruby_val = ruby_string_literal(literal);
491                let _ = writeln!(out, "      expect({header_expr}).to eq({ruby_val})");
492            }
493        }
494    }
495}
496
497// ---------------------------------------------------------------------------
498// Function-call test rendering
499// ---------------------------------------------------------------------------
500
501#[allow(clippy::too_many_arguments)]
502fn render_example(
503    out: &mut String,
504    fixture: &Fixture,
505    function_name: &str,
506    call_receiver: &str,
507    result_var: &str,
508    args: &[crate::config::ArgMapping],
509    field_resolver: &FieldResolver,
510    options_type: Option<&str>,
511    enum_fields: &HashMap<String, String>,
512    result_is_simple: bool,
513    e2e_config: &E2eConfig,
514) {
515    let test_name = sanitize_ident(&fixture.id);
516    let description = fixture.description.replace('\'', "\\'");
517    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
518
519    let (mut setup_lines, args_str) = build_args_and_setup(
520        &fixture.input,
521        args,
522        call_receiver,
523        options_type,
524        enum_fields,
525        result_is_simple,
526        &fixture.id,
527    );
528
529    // Build visitor if present and add to setup
530    let mut visitor_arg = String::new();
531    if let Some(visitor_spec) = &fixture.visitor {
532        visitor_arg = build_ruby_visitor(&mut setup_lines, visitor_spec);
533    }
534
535    let final_args = if visitor_arg.is_empty() {
536        args_str
537    } else if args_str.is_empty() {
538        visitor_arg
539    } else {
540        format!("{args_str}, {visitor_arg}")
541    };
542
543    let call_expr = format!("{call_receiver}.{function_name}({final_args})");
544
545    let _ = writeln!(out, "  it '{test_name}: {description}' do");
546
547    for line in &setup_lines {
548        let _ = writeln!(out, "    {line}");
549    }
550
551    if expects_error {
552        let _ = writeln!(out, "    expect {{ {call_expr} }}.to raise_error");
553        let _ = writeln!(out, "  end");
554        return;
555    }
556
557    // Check if any non-error assertion actually uses the result variable.
558    let has_usable = has_usable_assertion(fixture, field_resolver, result_is_simple);
559    let _ = writeln!(out, "    {result_var} = {call_expr}");
560
561    for assertion in &fixture.assertions {
562        render_assertion(out, assertion, result_var, field_resolver, result_is_simple, e2e_config);
563    }
564
565    // When all assertions were skipped (fields unavailable), the example has no
566    // expect() calls, which triggers rubocop's RSpec/NoExpectationExample cop.
567    // Emit a minimal placeholder expectation so rubocop is satisfied.
568    if !has_usable {
569        let _ = writeln!(out, "    expect({result_var}).not_to be_nil");
570    }
571
572    let _ = writeln!(out, "  end");
573}
574
575/// Build setup lines (e.g. handle creation) and the argument list for the function call.
576///
577/// Returns `(setup_lines, args_string)`.
578fn build_args_and_setup(
579    input: &serde_json::Value,
580    args: &[crate::config::ArgMapping],
581    call_receiver: &str,
582    options_type: Option<&str>,
583    enum_fields: &HashMap<String, String>,
584    result_is_simple: bool,
585    fixture_id: &str,
586) -> (Vec<String>, String) {
587    if args.is_empty() {
588        return (Vec::new(), json_to_ruby(input));
589    }
590
591    let mut setup_lines: Vec<String> = Vec::new();
592    let mut parts: Vec<String> = Vec::new();
593
594    for arg in args {
595        if arg.arg_type == "mock_url" {
596            setup_lines.push(format!(
597                "{} = \"#{{ENV.fetch('MOCK_SERVER_URL')}}/fixtures/{fixture_id}\"",
598                arg.name,
599            ));
600            parts.push(arg.name.clone());
601            continue;
602        }
603
604        if arg.arg_type == "handle" {
605            // Generate a create_engine (or equivalent) call and pass the variable.
606            let constructor_name = format!("create_{}", arg.name.to_snake_case());
607            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
608            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
609            if config_value.is_null()
610                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
611            {
612                setup_lines.push(format!("{} = {call_receiver}.{constructor_name}(nil)", arg.name,));
613            } else {
614                let literal = json_to_ruby(config_value);
615                let name = &arg.name;
616                setup_lines.push(format!("{name}_config = {literal}"));
617                setup_lines.push(format!(
618                    "{} = {call_receiver}.{constructor_name}({name}_config.to_json)",
619                    arg.name,
620                    name = name,
621                ));
622            }
623            parts.push(arg.name.clone());
624            continue;
625        }
626
627        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
628        let val = input.get(field);
629        match val {
630            None | Some(serde_json::Value::Null) if arg.optional => {
631                // Optional arg with no fixture value: skip entirely.
632                continue;
633            }
634            None | Some(serde_json::Value::Null) => {
635                // Required arg with no fixture value: pass a language-appropriate default.
636                let default_val = match arg.arg_type.as_str() {
637                    "string" => "''".to_string(),
638                    "int" | "integer" => "0".to_string(),
639                    "float" | "number" => "0.0".to_string(),
640                    "bool" | "boolean" => "false".to_string(),
641                    _ => "nil".to_string(),
642                };
643                parts.push(default_val);
644            }
645            Some(v) => {
646                // For json_object args with options_type, construct a typed options object.
647                // When result_is_simple, the binding accepts a plain Hash (no wrapper class).
648                if arg.arg_type == "json_object" && !v.is_null() {
649                    if let (Some(opts_type), Some(obj)) = (options_type, v.as_object()) {
650                        let kwargs: Vec<String> = obj
651                            .iter()
652                            .map(|(k, vv)| {
653                                let snake_key = k.to_snake_case();
654                                let rb_val = if enum_fields.contains_key(k) {
655                                    if let Some(s) = vv.as_str() {
656                                        let snake_val = s.to_snake_case();
657                                        format!("'{snake_val}'")
658                                    } else {
659                                        json_to_ruby(vv)
660                                    }
661                                } else {
662                                    json_to_ruby(vv)
663                                };
664                                format!("{snake_key}: {rb_val}")
665                            })
666                            .collect();
667                        if result_is_simple {
668                            parts.push(format!("{{{}}}", kwargs.join(", ")));
669                        } else {
670                            parts.push(format!("{opts_type}.new({})", kwargs.join(", ")));
671                        }
672                        continue;
673                    }
674                }
675                parts.push(json_to_ruby(v));
676            }
677        }
678    }
679
680    (setup_lines, parts.join(", "))
681}
682
683fn render_assertion(
684    out: &mut String,
685    assertion: &Assertion,
686    result_var: &str,
687    field_resolver: &FieldResolver,
688    result_is_simple: bool,
689    e2e_config: &E2eConfig,
690) {
691    // Skip assertions on fields that don't exist on the result type.
692    if let Some(f) = &assertion.field {
693        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
694            let _ = writeln!(out, "    # skipped: field '{f}' not available on result type");
695            return;
696        }
697    }
698
699    // When result_is_simple, skip assertions that reference non-content fields.
700    if result_is_simple {
701        if let Some(f) = &assertion.field {
702            let f_lower = f.to_lowercase();
703            if !f.is_empty()
704                && f_lower != "content"
705                && (f_lower.starts_with("metadata")
706                    || f_lower.starts_with("document")
707                    || f_lower.starts_with("structure"))
708            {
709                return;
710            }
711        }
712    }
713
714    let field_expr = if result_is_simple {
715        result_var.to_string()
716    } else {
717        match &assertion.field {
718            Some(f) if !f.is_empty() => field_resolver.accessor(f, "ruby", result_var),
719            _ => result_var.to_string(),
720        }
721    };
722
723    // For string equality, strip trailing whitespace to handle trailing newlines
724    // from the converter.
725    let stripped_field_expr = if result_is_simple {
726        format!("{field_expr}.strip")
727    } else {
728        field_expr.clone()
729    };
730
731    match assertion.assertion_type.as_str() {
732        "equals" => {
733            if let Some(expected) = &assertion.value {
734                // Use be(true)/be(false) for booleans (RSpec/BeEq).
735                if let Some(b) = expected.as_bool() {
736                    let _ = writeln!(out, "    expect({stripped_field_expr}).to be({b})");
737                } else {
738                    let rb_val = json_to_ruby(expected);
739                    let _ = writeln!(out, "    expect({stripped_field_expr}).to eq({rb_val})");
740                }
741            }
742        }
743        "contains" => {
744            if let Some(expected) = &assertion.value {
745                let rb_val = json_to_ruby(expected);
746                // Use .to_s to handle both String and Symbol (enum) fields
747                let _ = writeln!(out, "    expect({field_expr}.to_s).to include({rb_val})");
748            }
749        }
750        "contains_all" => {
751            if let Some(values) = &assertion.values {
752                for val in values {
753                    let rb_val = json_to_ruby(val);
754                    let _ = writeln!(out, "    expect({field_expr}.to_s).to include({rb_val})");
755                }
756            }
757        }
758        "not_contains" => {
759            if let Some(expected) = &assertion.value {
760                let rb_val = json_to_ruby(expected);
761                let _ = writeln!(out, "    expect({field_expr}.to_s).not_to include({rb_val})");
762            }
763        }
764        "not_empty" => {
765            let _ = writeln!(out, "    expect({field_expr}).not_to be_empty");
766        }
767        "is_empty" => {
768            // Handle nil (None) as empty for optional fields
769            let _ = writeln!(out, "    expect({field_expr}.nil? || {field_expr}.empty?).to be(true)");
770        }
771        "contains_any" => {
772            if let Some(values) = &assertion.values {
773                let items: Vec<String> = values.iter().map(json_to_ruby).collect();
774                let arr_str = items.join(", ");
775                let _ = writeln!(
776                    out,
777                    "    expect([{arr_str}].any? {{ |v| {field_expr}.to_s.include?(v) }}).to be(true)"
778                );
779            }
780        }
781        "greater_than" => {
782            if let Some(val) = &assertion.value {
783                let rb_val = json_to_ruby(val);
784                let _ = writeln!(out, "    expect({field_expr}).to be > {rb_val}");
785            }
786        }
787        "less_than" => {
788            if let Some(val) = &assertion.value {
789                let rb_val = json_to_ruby(val);
790                let _ = writeln!(out, "    expect({field_expr}).to be < {rb_val}");
791            }
792        }
793        "greater_than_or_equal" => {
794            if let Some(val) = &assertion.value {
795                let rb_val = json_to_ruby(val);
796                let _ = writeln!(out, "    expect({field_expr}).to be >= {rb_val}");
797            }
798        }
799        "less_than_or_equal" => {
800            if let Some(val) = &assertion.value {
801                let rb_val = json_to_ruby(val);
802                let _ = writeln!(out, "    expect({field_expr}).to be <= {rb_val}");
803            }
804        }
805        "starts_with" => {
806            if let Some(expected) = &assertion.value {
807                let rb_val = json_to_ruby(expected);
808                let _ = writeln!(out, "    expect({field_expr}).to start_with({rb_val})");
809            }
810        }
811        "ends_with" => {
812            if let Some(expected) = &assertion.value {
813                let rb_val = json_to_ruby(expected);
814                let _ = writeln!(out, "    expect({field_expr}).to end_with({rb_val})");
815            }
816        }
817        "min_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        "max_length" => {
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_min" => {
832            if let Some(val) = &assertion.value {
833                if let Some(n) = val.as_u64() {
834                    let _ = writeln!(out, "    expect({field_expr}.length).to be >= {n}");
835                }
836            }
837        }
838        "count_equals" => {
839            if let Some(val) = &assertion.value {
840                if let Some(n) = val.as_u64() {
841                    let _ = writeln!(out, "    expect({field_expr}.length).to eq({n})");
842                }
843            }
844        }
845        "is_true" => {
846            let _ = writeln!(out, "    expect({field_expr}).to be true");
847        }
848        "is_false" => {
849            let _ = writeln!(out, "    expect({field_expr}).to be false");
850        }
851        "method_result" => {
852            if let Some(method_name) = &assertion.method {
853                // Derive call_receiver for module-level helper calls.
854                let lang = "ruby";
855                let call = &e2e_config.call;
856                let overrides = call.overrides.get(lang);
857                let module_path = overrides
858                    .and_then(|o| o.module.as_ref())
859                    .cloned()
860                    .unwrap_or_else(|| call.module.clone());
861                let call_receiver = ruby_module_name(&module_path);
862
863                let call_expr =
864                    build_ruby_method_call(&call_receiver, result_var, method_name, assertion.args.as_ref());
865                let check = assertion.check.as_deref().unwrap_or("is_true");
866                match check {
867                    "equals" => {
868                        if let Some(val) = &assertion.value {
869                            if let Some(b) = val.as_bool() {
870                                let _ = writeln!(out, "    expect({call_expr}).to be {b}");
871                            } else {
872                                let rb_val = json_to_ruby(val);
873                                let _ = writeln!(out, "    expect({call_expr}).to eq({rb_val})");
874                            }
875                        }
876                    }
877                    "is_true" => {
878                        let _ = writeln!(out, "    expect({call_expr}).to be true");
879                    }
880                    "is_false" => {
881                        let _ = writeln!(out, "    expect({call_expr}).to be false");
882                    }
883                    "greater_than_or_equal" => {
884                        if let Some(val) = &assertion.value {
885                            let rb_val = json_to_ruby(val);
886                            let _ = writeln!(out, "    expect({call_expr}).to be >= {rb_val}");
887                        }
888                    }
889                    "count_min" => {
890                        if let Some(val) = &assertion.value {
891                            let n = val.as_u64().unwrap_or(0);
892                            let _ = writeln!(out, "    expect({call_expr}.length).to be >= {n}");
893                        }
894                    }
895                    "is_error" => {
896                        let _ = writeln!(out, "    expect {{ {call_expr} }}.to raise_error");
897                    }
898                    "contains" => {
899                        if let Some(val) = &assertion.value {
900                            let rb_val = json_to_ruby(val);
901                            let _ = writeln!(out, "    expect({call_expr}).to include({rb_val})");
902                        }
903                    }
904                    other_check => {
905                        panic!("Ruby e2e generator: unsupported method_result check type: {other_check}");
906                    }
907                }
908            } else {
909                panic!("Ruby e2e generator: method_result assertion missing 'method' field");
910            }
911        }
912        "matches_regex" => {
913            if let Some(expected) = &assertion.value {
914                let rb_val = json_to_ruby(expected);
915                let _ = writeln!(out, "    expect({field_expr}).to match({rb_val})");
916            }
917        }
918        "not_error" => {
919            // Already handled by the call succeeding without exception.
920        }
921        "error" => {
922            // Handled at the example level.
923        }
924        other => {
925            panic!("Ruby e2e generator: unsupported assertion type: {other}");
926        }
927    }
928}
929
930/// Build a Ruby call expression for a `method_result` assertion on a tree-sitter Tree.
931/// Maps method names to the appropriate Ruby method or module-function calls.
932fn build_ruby_method_call(
933    call_receiver: &str,
934    result_var: &str,
935    method_name: &str,
936    args: Option<&serde_json::Value>,
937) -> String {
938    match method_name {
939        "root_child_count" => format!("{result_var}.root_node.child_count"),
940        "root_node_type" => format!("{result_var}.root_node.type"),
941        "named_children_count" => format!("{result_var}.root_node.named_child_count"),
942        "has_error_nodes" => format!("{call_receiver}.tree_has_error_nodes({result_var})"),
943        "error_count" | "tree_error_count" => format!("{call_receiver}.tree_error_count({result_var})"),
944        "tree_to_sexp" => format!("{call_receiver}.tree_to_sexp({result_var})"),
945        "contains_node_type" => {
946            let node_type = args
947                .and_then(|a| a.get("node_type"))
948                .and_then(|v| v.as_str())
949                .unwrap_or("");
950            format!("{call_receiver}.tree_contains_node_type({result_var}, \"{node_type}\")")
951        }
952        "find_nodes_by_type" => {
953            let node_type = args
954                .and_then(|a| a.get("node_type"))
955                .and_then(|v| v.as_str())
956                .unwrap_or("");
957            format!("{call_receiver}.find_nodes_by_type({result_var}, \"{node_type}\")")
958        }
959        "run_query" => {
960            let query_source = args
961                .and_then(|a| a.get("query_source"))
962                .and_then(|v| v.as_str())
963                .unwrap_or("");
964            let language = args
965                .and_then(|a| a.get("language"))
966                .and_then(|v| v.as_str())
967                .unwrap_or("");
968            format!("{call_receiver}.run_query({result_var}, \"{language}\", \"{query_source}\", source)")
969        }
970        _ => format!("{result_var}.{method_name}"),
971    }
972}
973
974/// Convert a module path (e.g., "html_to_markdown") to Ruby PascalCase module name
975/// (e.g., "HtmlToMarkdown").
976fn ruby_module_name(module_path: &str) -> String {
977    use heck::ToUpperCamelCase;
978    module_path.to_upper_camel_case()
979}
980
981/// Convert a `serde_json::Value` to a Ruby literal string, preferring single quotes.
982fn json_to_ruby(value: &serde_json::Value) -> String {
983    match value {
984        serde_json::Value::String(s) => ruby_string_literal(s),
985        serde_json::Value::Bool(true) => "true".to_string(),
986        serde_json::Value::Bool(false) => "false".to_string(),
987        serde_json::Value::Number(n) => n.to_string(),
988        serde_json::Value::Null => "nil".to_string(),
989        serde_json::Value::Array(arr) => {
990            let items: Vec<String> = arr.iter().map(json_to_ruby).collect();
991            format!("[{}]", items.join(", "))
992        }
993        serde_json::Value::Object(map) => {
994            let items: Vec<String> = map
995                .iter()
996                .map(|(k, v)| format!("{} => {}", ruby_string_literal(k), json_to_ruby(v)))
997                .collect();
998            format!("{{ {} }}", items.join(", "))
999        }
1000    }
1001}
1002
1003// ---------------------------------------------------------------------------
1004// Visitor generation
1005// ---------------------------------------------------------------------------
1006
1007/// Build a Ruby visitor object and add setup lines. Returns the visitor expression.
1008fn build_ruby_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1009    setup_lines.push("visitor = Class.new do".to_string());
1010    for (method_name, action) in &visitor_spec.callbacks {
1011        emit_ruby_visitor_method(setup_lines, method_name, action);
1012    }
1013    setup_lines.push("end.new".to_string());
1014    "visitor".to_string()
1015}
1016
1017/// Emit a Ruby visitor method for a callback action.
1018fn emit_ruby_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
1019    let snake_method = method_name;
1020    let params = match method_name {
1021        "visit_link" => "ctx, href, text, title",
1022        "visit_image" => "ctx, src, alt, title",
1023        "visit_heading" => "ctx, level, text, id",
1024        "visit_code_block" => "ctx, lang, code",
1025        "visit_code_inline"
1026        | "visit_strong"
1027        | "visit_emphasis"
1028        | "visit_strikethrough"
1029        | "visit_underline"
1030        | "visit_subscript"
1031        | "visit_superscript"
1032        | "visit_mark"
1033        | "visit_button"
1034        | "visit_summary"
1035        | "visit_figcaption"
1036        | "visit_definition_term"
1037        | "visit_definition_description" => "ctx, text",
1038        "visit_text" => "ctx, text",
1039        "visit_list_item" => "ctx, ordered, marker, text",
1040        "visit_blockquote" => "ctx, content, depth",
1041        "visit_table_row" => "ctx, cells, is_header",
1042        "visit_custom_element" => "ctx, tag_name, html",
1043        "visit_form" => "ctx, action_url, method",
1044        "visit_input" => "ctx, input_type, name, value",
1045        "visit_audio" | "visit_video" | "visit_iframe" => "ctx, src",
1046        "visit_details" => "ctx, is_open",
1047        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "ctx, output",
1048        "visit_list_start" => "ctx, ordered",
1049        "visit_list_end" => "ctx, ordered, output",
1050        _ => "ctx",
1051    };
1052
1053    setup_lines.push(format!("  def {snake_method}({params})"));
1054    match action {
1055        CallbackAction::Skip => {
1056            setup_lines.push("    'skip'".to_string());
1057        }
1058        CallbackAction::Continue => {
1059            setup_lines.push("    'continue'".to_string());
1060        }
1061        CallbackAction::PreserveHtml => {
1062            setup_lines.push("    'preserve_html'".to_string());
1063        }
1064        CallbackAction::Custom { output } => {
1065            let escaped = ruby_string_literal(output);
1066            setup_lines.push(format!("    {{ custom: {escaped} }}"));
1067        }
1068        CallbackAction::CustomTemplate { template } => {
1069            let escaped = ruby_string_literal(template);
1070            setup_lines.push(format!("    {{ custom: {escaped} }}"));
1071        }
1072    }
1073    setup_lines.push("  end".to_string());
1074}