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::{Assertion, CallbackAction, Fixture, FixtureGroup, ValidationErrorExpectation};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::AlefConfig;
12use alef_core::hash::{self, CommentStyle};
13use alef_core::template_versions as tv;
14use anyhow::Result;
15use heck::ToSnakeCase;
16use std::collections::HashMap;
17use std::fmt::Write as FmtWrite;
18use std::path::PathBuf;
19
20use super::E2eCodegen;
21use super::client;
22
23/// Ruby e2e code generator.
24pub 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        // Resolve call config with overrides.
39        let call = &e2e_config.call;
40        let overrides = call.overrides.get(lang);
41        let module_path = overrides
42            .and_then(|o| o.module.as_ref())
43            .cloned()
44            .unwrap_or_else(|| call.module.clone());
45        let class_name = overrides.and_then(|o| o.class.as_ref()).cloned();
46        let options_type = overrides.and_then(|o| o.options_type.clone());
47        let empty_enum_fields = HashMap::new();
48        let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
49        let result_is_simple = call.result_is_simple || overrides.is_some_and(|o| o.result_is_simple);
50
51        // Resolve package config.
52        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        // Generate Gemfile.
70        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        // Generate .rubocop.yaml for linting generated specs.
77        files.push(GeneratedFile {
78            path: output_base.join(".rubocop.yaml"),
79            content: render_rubocop_yaml(),
80            generated_header: false,
81        });
82
83        // Check if any fixture is an HTTP test (needs mock server bootstrap).
84        let has_http_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| f.is_http_test());
85
86        // Check if any fixture uses file_path or bytes args (needs chdir to test_documents).
87        let has_file_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| {
88            let cc = e2e_config.resolve_call(f.call.as_deref());
89            cc.args
90                .iter()
91                .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
92        });
93
94        // Always generate spec/spec_helper.rb when file-based or HTTP fixtures are present.
95        if has_file_fixtures || has_http_fixtures {
96            files.push(GeneratedFile {
97                path: output_base.join("spec").join("spec_helper.rb"),
98                content: render_spec_helper(has_file_fixtures, has_http_fixtures),
99                generated_header: true,
100            });
101        }
102
103        // Generate spec files per category.
104        let spec_base = output_base.join("spec");
105
106        for group in groups {
107            let active: Vec<&Fixture> = group
108                .fixtures
109                .iter()
110                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
111                .collect();
112
113            if active.is_empty() {
114                continue;
115            }
116
117            let field_resolver_pre = FieldResolver::new(
118                &e2e_config.fields,
119                &e2e_config.fields_optional,
120                &e2e_config.result_fields,
121                &e2e_config.fields_array,
122            );
123            // Skip the entire file if no fixture in this category produces output.
124            let has_any_output = active.iter().any(|f| {
125                // HTTP tests always produce output.
126                if f.is_http_test() {
127                    return true;
128                }
129                let expects_error = f.assertions.iter().any(|a| a.assertion_type == "error");
130                expects_error || has_usable_assertion(f, &field_resolver_pre, result_is_simple)
131            });
132            if !has_any_output {
133                continue;
134            }
135
136            let filename = format!("{}_spec.rb", sanitize_filename(&group.category));
137            let field_resolver = FieldResolver::new(
138                &e2e_config.fields,
139                &e2e_config.fields_optional,
140                &e2e_config.result_fields,
141                &e2e_config.fields_array,
142            );
143            let content = render_spec_file(
144                &group.category,
145                &active,
146                &module_path,
147                class_name.as_deref(),
148                &gem_name,
149                &field_resolver,
150                options_type.as_deref(),
151                enum_fields,
152                result_is_simple,
153                e2e_config,
154                has_file_fixtures || has_http_fixtures,
155            );
156            files.push(GeneratedFile {
157                path: spec_base.join(filename),
158                content,
159                generated_header: true,
160            });
161        }
162
163        Ok(files)
164    }
165
166    fn language_name(&self) -> &'static str {
167        "ruby"
168    }
169}
170
171// ---------------------------------------------------------------------------
172// Rendering
173// ---------------------------------------------------------------------------
174
175fn render_gemfile(
176    gem_name: &str,
177    gem_path: &str,
178    gem_version: &str,
179    dep_mode: crate::config::DependencyMode,
180) -> String {
181    let gem_line = match dep_mode {
182        crate::config::DependencyMode::Registry => format!("gem '{gem_name}', '{gem_version}'"),
183        crate::config::DependencyMode::Local => format!("gem '{gem_name}', path: '{gem_path}'"),
184    };
185    format!(
186        "# frozen_string_literal: true\n\
187         \n\
188         source 'https://rubygems.org'\n\
189         \n\
190         {gem_line}\n\
191         gem 'rspec', '{rspec}'\n\
192         gem 'rubocop', '{rubocop}'\n\
193         gem 'rubocop-rspec', '{rubocop_rspec}'\n\
194         gem 'faraday', '{faraday}'\n",
195        rspec = tv::gem::RSPEC_E2E,
196        rubocop = tv::gem::RUBOCOP_E2E,
197        rubocop_rspec = tv::gem::RUBOCOP_RSPEC_E2E,
198        faraday = tv::gem::FARADAY,
199    )
200}
201
202fn render_spec_helper(has_file_fixtures: bool, has_http_fixtures: bool) -> String {
203    let header = hash::header(CommentStyle::Hash);
204    let mut out = header;
205    out.push_str("# frozen_string_literal: true\n");
206
207    if has_file_fixtures {
208        out.push_str(
209            r#"
210# Change to the test_documents directory so that fixture file paths like
211# "pdf/fake_memo.pdf" resolve correctly when running rspec from e2e/ruby/.
212# spec_helper.rb lives in e2e/ruby/spec/; test_documents lives at the
213# repository root, three directories up: spec/ -> e2e/ruby/ -> e2e/ -> root.
214_test_documents = File.expand_path('../../../test_documents', __dir__)
215Dir.chdir(_test_documents) if Dir.exist?(_test_documents)
216"#,
217        );
218    }
219
220    if has_http_fixtures {
221        out.push_str(
222            r#"
223require 'open3'
224
225# Spawn the mock-server binary and set MOCK_SERVER_URL for all tests.
226RSpec.configure do |config|
227  config.before(:suite) do
228    bin = File.expand_path('../../rust/target/release/mock-server', __dir__)
229    fixtures_dir = File.expand_path('../../../fixtures', __dir__)
230    unless File.exist?(bin)
231      warn "mock-server binary not found at #{bin} — run: cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release"
232    end
233    stdin, stdout, _stderr, _wait = Open3.popen3(bin, fixtures_dir)
234    url = stdout.readline.strip.split('=', 2).last
235    ENV['MOCK_SERVER_URL'] = url
236    # Drain stdout in background.
237    Thread.new { stdout.read }
238    # Store stdin so we can close it on teardown.
239    @_mock_server_stdin = stdin
240  end
241
242  config.after(:suite) do
243    @_mock_server_stdin&.close
244  end
245end
246"#,
247        );
248    }
249
250    out
251}
252
253fn render_rubocop_yaml() -> String {
254    r#"# Generated by alef e2e — do not edit.
255AllCops:
256  NewCops: enable
257  TargetRubyVersion: 3.2
258  SuggestExtensions: false
259
260plugins:
261  - rubocop-rspec
262
263# --- Justified suppressions for generated test code ---
264
265# Generated tests are verbose by nature (setup + multiple assertions).
266Metrics/BlockLength:
267  Enabled: false
268Metrics/MethodLength:
269  Enabled: false
270Layout/LineLength:
271  Enabled: false
272
273# Generated tests use multiple assertions per example for thorough verification.
274RSpec/MultipleExpectations:
275  Enabled: false
276RSpec/ExampleLength:
277  Enabled: false
278
279# Generated tests describe categories as strings, not classes.
280RSpec/DescribeClass:
281  Enabled: false
282
283# Fixture-driven tests may produce identical assertion bodies for different inputs.
284RSpec/RepeatedExample:
285  Enabled: false
286
287# Error-handling tests use bare raise_error (exception type not known at generation time).
288RSpec/UnspecifiedException:
289  Enabled: false
290"#
291    .to_string()
292}
293
294#[allow(clippy::too_many_arguments)]
295fn render_spec_file(
296    category: &str,
297    fixtures: &[&Fixture],
298    module_path: &str,
299    class_name: Option<&str>,
300    gem_name: &str,
301    field_resolver: &FieldResolver,
302    options_type: Option<&str>,
303    enum_fields: &HashMap<String, String>,
304    result_is_simple: bool,
305    e2e_config: &E2eConfig,
306    needs_spec_helper: bool,
307) -> String {
308    let mut out = String::new();
309    out.push_str(&hash::header(CommentStyle::Hash));
310    let _ = writeln!(out, "# frozen_string_literal: true");
311    let _ = writeln!(out);
312
313    // Require the gem (single quotes).
314    let require_name = if module_path.is_empty() { gem_name } else { module_path };
315    let _ = writeln!(out, "require '{}'", require_name.replace('-', "_"));
316    let _ = writeln!(out, "require 'json'");
317
318    let has_http = fixtures.iter().any(|f| f.is_http_test());
319    if needs_spec_helper || has_http {
320        // spec_helper sets up Dir.chdir and/or mock server.
321        let _ = writeln!(out, "require_relative 'spec_helper'");
322    }
323    let _ = writeln!(out);
324
325    // Build the Ruby module/class qualifier for calls.
326    let call_receiver = class_name
327        .map(|s| s.to_string())
328        .unwrap_or_else(|| ruby_module_name(module_path));
329
330    let _ = writeln!(out, "RSpec.describe '{}' do", category);
331
332    // Emit a shared client helper when there are HTTP tests.
333    if has_http {
334        let _ = writeln!(
335            out,
336            "  let(:mock_server_url) {{ ENV.fetch('MOCK_SERVER_URL', 'http://localhost:8080') }}"
337        );
338        let _ = writeln!(out);
339    }
340
341    let mut first = true;
342    for fixture in fixtures {
343        if !first {
344            let _ = writeln!(out);
345        }
346        first = false;
347
348        if fixture.http.is_some() {
349            render_http_example(&mut out, fixture);
350        } else {
351            // Non-HTTP fixtures (WebSocket, SSE, gRPC, etc.) that have no usable assertions
352            // cannot be tested via Net::HTTP. Emit a pending example instead.
353            let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
354            let has_usable = has_usable_assertion(fixture, field_resolver, result_is_simple);
355            if !expects_error && !has_usable {
356                let test_name = sanitize_ident(&fixture.id);
357                let description = fixture.description.replace('\'', "\\'");
358                let _ = writeln!(out, "  it '{test_name}: {description}' do");
359                let _ = writeln!(out, "    skip 'Non-HTTP fixture cannot be tested via Net::HTTP'");
360                let _ = writeln!(out, "  end");
361            } else {
362                // Resolve per-fixture call config (supports named calls via fixture.call field).
363                let fixture_call = e2e_config.resolve_call(fixture.call.as_deref());
364                let fixture_call_overrides = fixture_call.overrides.get("ruby");
365                let fixture_function_name = fixture_call_overrides
366                    .and_then(|o| o.function.as_ref())
367                    .cloned()
368                    .unwrap_or_else(|| fixture_call.function.clone());
369                let fixture_result_var = &fixture_call.result_var;
370                let fixture_args = &fixture_call.args;
371                render_example(
372                    &mut out,
373                    fixture,
374                    &fixture_function_name,
375                    &call_receiver,
376                    fixture_result_var,
377                    fixture_args,
378                    field_resolver,
379                    options_type,
380                    enum_fields,
381                    result_is_simple,
382                    e2e_config,
383                );
384            }
385        }
386    }
387
388    let _ = writeln!(out, "end");
389    out
390}
391
392/// Check if a fixture has at least one assertion that will produce an executable
393/// expect() call (not just a skip comment).
394fn has_usable_assertion(fixture: &Fixture, field_resolver: &FieldResolver, result_is_simple: bool) -> bool {
395    fixture.assertions.iter().any(|a| {
396        // not_error is implicit (call succeeding), error is handled separately.
397        if a.assertion_type == "not_error" || a.assertion_type == "error" {
398            return false;
399        }
400        // Check field validity.
401        if let Some(f) = &a.field {
402            if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
403                return false;
404            }
405            // When result_is_simple, skip non-content fields.
406            if result_is_simple {
407                let f_lower = f.to_lowercase();
408                if !f.is_empty()
409                    && f_lower != "content"
410                    && (f_lower.starts_with("metadata")
411                        || f_lower.starts_with("document")
412                        || f_lower.starts_with("structure"))
413                {
414                    return false;
415                }
416            }
417        }
418        true
419    })
420}
421
422// ---------------------------------------------------------------------------
423// HTTP test rendering — shared-driver integration
424// ---------------------------------------------------------------------------
425
426/// Thin renderer that emits RSpec `describe` + `it` blocks targeting a mock server
427/// via `Net::HTTP`. Satisfies [`client::TestClientRenderer`] so the shared
428/// [`client::http_call::render_http_test`] driver drives the call sequence.
429struct RubyTestClientRenderer;
430
431impl client::TestClientRenderer for RubyTestClientRenderer {
432    fn language_name(&self) -> &'static str {
433        "ruby"
434    }
435
436    /// Emit `describe '{fn_name}' do` + inner `it '{description}' do`.
437    ///
438    /// `fn_name` is the sanitised fixture id used as the describe label.
439    /// When `skip_reason` is `Some`, the inner `it` block gets a `skip` call so
440    /// the shared driver short-circuits before emitting any assertions.
441    fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
442        let escaped_description = description.replace('\'', "\\'");
443        let _ = writeln!(out, "  describe '{fn_name}' do");
444        if let Some(reason) = skip_reason {
445            let _ = writeln!(out, "    it '{escaped_description}' do");
446            let _ = writeln!(out, "      skip '{reason}'");
447            // NB: the shared driver calls render_test_close immediately after render_test_open
448            // for skipped fixtures, so the closing `end`s are emitted there.
449        } else {
450            let _ = writeln!(out, "    it '{escaped_description}' do");
451        }
452    }
453
454    /// Close the inner `it` block and the outer `describe` block.
455    fn render_test_close(&self, out: &mut String) {
456        let _ = writeln!(out, "    end");
457        let _ = writeln!(out, "  end");
458    }
459
460    /// Emit a `Net::HTTP` request to the mock server using the path from `ctx`.
461    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
462        let method = ctx.method.to_uppercase();
463        let method_class = http_method_class(&method);
464        let _ = writeln!(out, "      require 'net/http'");
465        let _ = writeln!(out, "      require 'uri'");
466        let _ = writeln!(out, "      require 'json'");
467        let _ = writeln!(out, "      _uri = URI.parse(\"#{{mock_server_url}}{}\")", ctx.path);
468        let _ = writeln!(out, "      _http = Net::HTTP.new(_uri.host, _uri.port)");
469        let _ = writeln!(out, "      _http.use_ssl = _uri.scheme == 'https'");
470        let _ = writeln!(out, "      _req = Net::HTTP::{method_class}.new(_uri.request_uri)");
471
472        let has_body = ctx
473            .body
474            .is_some_and(|b| !matches!(b, serde_json::Value::String(s) if s.is_empty()));
475        if has_body {
476            let ruby_body = json_to_ruby(ctx.body.unwrap());
477            let _ = writeln!(out, "      _req.body = {ruby_body}.to_json");
478            let _ = writeln!(out, "      _req['Content-Type'] = 'application/json'");
479        }
480
481        for (k, v) in ctx.headers {
482            // Skip Content-Type when already set from the body above.
483            if has_body && k.to_lowercase() == "content-type" {
484                continue;
485            }
486            let rk = ruby_string_literal(k);
487            let rv = ruby_string_literal(v);
488            let _ = writeln!(out, "      _req[{rk}] = {rv}");
489        }
490
491        let _ = writeln!(out, "      {} = _http.request(_req)", ctx.response_var);
492    }
493
494    /// Emit `expect(response.code.to_i).to eq(status)`.
495    ///
496    /// Net::HTTP returns the HTTP status as a `String`; `.to_i` converts it for
497    /// comparison with the integer literal from the fixture.
498    fn render_assert_status(&self, out: &mut String, response_var: &str, status: u16) {
499        let _ = writeln!(out, "      expect({response_var}.code.to_i).to eq({status})");
500    }
501
502    /// Emit a header assertion using `response[header_key]`.
503    ///
504    /// Handles the three special tokens: `<<present>>`, `<<absent>>`, `<<uuid>>`.
505    fn render_assert_header(&self, out: &mut String, response_var: &str, name: &str, expected: &str) {
506        let header_key = name.to_lowercase();
507        let header_expr = format!("{response_var}[{}]", ruby_string_literal(&header_key));
508        match expected {
509            "<<present>>" => {
510                let _ = writeln!(out, "      expect({header_expr}).not_to be_nil");
511            }
512            "<<absent>>" => {
513                let _ = writeln!(out, "      expect({header_expr}).to be_nil");
514            }
515            "<<uuid>>" => {
516                let _ = writeln!(
517                    out,
518                    "      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)"
519                );
520            }
521            literal => {
522                let ruby_val = ruby_string_literal(literal);
523                let _ = writeln!(out, "      expect({header_expr}).to eq({ruby_val})");
524            }
525        }
526    }
527
528    /// Emit a full JSON body equality assertion.
529    ///
530    /// Plain string bodies are compared as raw text; structured bodies are parsed
531    /// with `JSON.parse` and compared as Ruby Hash/Array values.
532    fn render_assert_json_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
533        match expected {
534            serde_json::Value::String(s) => {
535                let ruby_val = ruby_string_literal(s);
536                let _ = writeln!(out, "      expect({response_var}.body).to eq({ruby_val})");
537            }
538            _ => {
539                let ruby_val = json_to_ruby(expected);
540                let _ = writeln!(
541                    out,
542                    "      _body = {response_var}.body && !{response_var}.body.empty? ? JSON.parse({response_var}.body) : nil"
543                );
544                let _ = writeln!(out, "      expect(_body).to eq({ruby_val})");
545            }
546        }
547    }
548
549    /// Emit partial body assertions: one `expect(_body[key]).to eq(val)` per field.
550    fn render_assert_partial_body(&self, out: &mut String, response_var: &str, expected: &serde_json::Value) {
551        if let Some(obj) = expected.as_object() {
552            let _ = writeln!(out, "      _body = JSON.parse({response_var}.body)");
553            for (key, val) in obj {
554                let ruby_key = ruby_string_literal(key);
555                let ruby_val = json_to_ruby(val);
556                let _ = writeln!(out, "      expect(_body[{ruby_key}]).to eq({ruby_val})");
557            }
558        }
559    }
560
561    /// Emit validation-error assertions, checking each expected `msg` against the
562    /// parsed body's `errors` array.
563    fn render_assert_validation_errors(
564        &self,
565        out: &mut String,
566        response_var: &str,
567        errors: &[ValidationErrorExpectation],
568    ) {
569        for err in errors {
570            let msg_lit = ruby_string_literal(&err.msg);
571            let _ = writeln!(out, "      _body = JSON.parse({response_var}.body)");
572            let _ = writeln!(out, "      _errors = _body['errors'] || []");
573            let _ = writeln!(
574                out,
575                "      expect(_errors.map {{ |e| e['msg'] }}).to include({msg_lit})"
576            );
577        }
578    }
579}
580
581/// Render an RSpec example for an HTTP server test fixture via the shared driver.
582///
583/// Delegates to [`client::http_call::render_http_test`] after handling the one
584/// Ruby-specific pre-condition: HTTP 101 (WebSocket upgrade) cannot be exercised
585/// via `Net::HTTP` and is emitted as a pending `it` block directly.
586fn render_http_example(out: &mut String, fixture: &Fixture) {
587    // HTTP 101 (WebSocket upgrade) cannot be tested via Net::HTTP.
588    // Emit the skip block directly rather than pushing a skip directive through
589    // the shared driver, which would require a full `fixture.skip` entry.
590    if fixture
591        .http
592        .as_ref()
593        .is_some_and(|h| h.expected_response.status_code == 101)
594    {
595        if let Some(http) = fixture.http.as_ref() {
596            let description = fixture.description.replace('\'', "\\'");
597            let method = http.request.method.to_uppercase();
598            let path = &http.request.path;
599            let _ = writeln!(out, "  describe '{method} {path}' do");
600            let _ = writeln!(out, "    it '{description}' do");
601            let _ = writeln!(
602                out,
603                "      skip 'HTTP 101 WebSocket upgrade cannot be tested via Net::HTTP'"
604            );
605            let _ = writeln!(out, "    end");
606            let _ = writeln!(out, "  end");
607        }
608        return;
609    }
610
611    client::http_call::render_http_test(out, &RubyTestClientRenderer, fixture);
612}
613
614/// Convert an uppercase HTTP method string to Ruby's Net::HTTP class name.
615/// Ruby uses title-cased names: Get, Post, Put, Delete, Patch, Head, Options, Trace.
616fn http_method_class(method: &str) -> String {
617    let mut chars = method.chars();
618    match chars.next() {
619        None => String::new(),
620        Some(first) => first.to_uppercase().collect::<String>() + &chars.as_str().to_lowercase(),
621    }
622}
623
624// ---------------------------------------------------------------------------
625// Function-call test rendering
626// ---------------------------------------------------------------------------
627
628#[allow(clippy::too_many_arguments)]
629fn render_example(
630    out: &mut String,
631    fixture: &Fixture,
632    function_name: &str,
633    call_receiver: &str,
634    result_var: &str,
635    args: &[crate::config::ArgMapping],
636    field_resolver: &FieldResolver,
637    options_type: Option<&str>,
638    enum_fields: &HashMap<String, String>,
639    result_is_simple: bool,
640    e2e_config: &E2eConfig,
641) {
642    let test_name = sanitize_ident(&fixture.id);
643    let description = fixture.description.replace('\'', "\\'");
644    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
645
646    let (mut setup_lines, args_str) = build_args_and_setup(
647        &fixture.input,
648        args,
649        call_receiver,
650        options_type,
651        enum_fields,
652        result_is_simple,
653        &fixture.id,
654    );
655
656    // Build visitor if present and add to setup
657    let mut visitor_arg = String::new();
658    if let Some(visitor_spec) = &fixture.visitor {
659        visitor_arg = build_ruby_visitor(&mut setup_lines, visitor_spec);
660    }
661
662    let final_args = if visitor_arg.is_empty() {
663        args_str
664    } else if args_str.is_empty() {
665        visitor_arg
666    } else {
667        format!("{args_str}, {visitor_arg}")
668    };
669
670    let call_expr = format!("{call_receiver}.{function_name}({final_args})");
671
672    let _ = writeln!(out, "  it '{test_name}: {description}' do");
673
674    for line in &setup_lines {
675        let _ = writeln!(out, "    {line}");
676    }
677
678    if expects_error {
679        let _ = writeln!(out, "    expect {{ {call_expr} }}.to raise_error");
680        let _ = writeln!(out, "  end");
681        return;
682    }
683
684    // Check if any non-error assertion actually uses the result variable.
685    let has_usable = has_usable_assertion(fixture, field_resolver, result_is_simple);
686    let _ = writeln!(out, "    {result_var} = {call_expr}");
687
688    for assertion in &fixture.assertions {
689        render_assertion(out, assertion, result_var, field_resolver, result_is_simple, e2e_config);
690    }
691
692    // When all assertions were skipped (fields unavailable), the example has no
693    // expect() calls, which triggers rubocop's RSpec/NoExpectationExample cop.
694    // Emit a minimal placeholder expectation so rubocop is satisfied.
695    if !has_usable {
696        let _ = writeln!(out, "    expect({result_var}).not_to be_nil");
697    }
698
699    let _ = writeln!(out, "  end");
700}
701
702/// Build setup lines (e.g. handle creation) and the argument list for the function call.
703///
704/// Returns `(setup_lines, args_string)`.
705fn build_args_and_setup(
706    input: &serde_json::Value,
707    args: &[crate::config::ArgMapping],
708    call_receiver: &str,
709    options_type: Option<&str>,
710    enum_fields: &HashMap<String, String>,
711    result_is_simple: bool,
712    fixture_id: &str,
713) -> (Vec<String>, String) {
714    if args.is_empty() {
715        return (Vec::new(), json_to_ruby(input));
716    }
717
718    let mut setup_lines: Vec<String> = Vec::new();
719    let mut parts: Vec<String> = Vec::new();
720    // Track optional args that were skipped; if a later arg is emitted we must back-fill nil
721    // to preserve positional correctness (e.g. extract_file(path, nil, config)).
722    let mut skipped_optional_count: usize = 0;
723
724    for arg in args {
725        if arg.arg_type == "mock_url" {
726            // Flush any pending nil placeholders for skipped optionals before this positional arg.
727            for _ in 0..skipped_optional_count {
728                parts.push("nil".to_string());
729            }
730            skipped_optional_count = 0;
731            setup_lines.push(format!(
732                "{} = \"#{{ENV.fetch('MOCK_SERVER_URL')}}/fixtures/{fixture_id}\"",
733                arg.name,
734            ));
735            parts.push(arg.name.clone());
736            continue;
737        }
738
739        if arg.arg_type == "handle" {
740            // Flush any pending nil placeholders for skipped optionals before this positional arg.
741            for _ in 0..skipped_optional_count {
742                parts.push("nil".to_string());
743            }
744            skipped_optional_count = 0;
745            // Generate a create_engine (or equivalent) call and pass the variable.
746            let constructor_name = format!("create_{}", arg.name.to_snake_case());
747            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
748            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
749            if config_value.is_null()
750                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
751            {
752                setup_lines.push(format!("{} = {call_receiver}.{constructor_name}(nil)", arg.name,));
753            } else {
754                let literal = json_to_ruby(config_value);
755                let name = &arg.name;
756                setup_lines.push(format!("{name}_config = {literal}"));
757                setup_lines.push(format!(
758                    "{} = {call_receiver}.{constructor_name}({name}_config.to_json)",
759                    arg.name,
760                    name = name,
761                ));
762            }
763            parts.push(arg.name.clone());
764            continue;
765        }
766
767        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
768        let val = input.get(field);
769        match val {
770            None | Some(serde_json::Value::Null) if arg.optional => {
771                // Optional arg with no fixture value: defer; emit nil only if a later arg is present.
772                skipped_optional_count += 1;
773                continue;
774            }
775            None | Some(serde_json::Value::Null) => {
776                // Required arg with no fixture value: flush deferred nils, then pass a default.
777                for _ in 0..skipped_optional_count {
778                    parts.push("nil".to_string());
779                }
780                skipped_optional_count = 0;
781                let default_val = match arg.arg_type.as_str() {
782                    "string" => "''".to_string(),
783                    "int" | "integer" => "0".to_string(),
784                    "float" | "number" => "0.0".to_string(),
785                    "bool" | "boolean" => "false".to_string(),
786                    _ => "nil".to_string(),
787                };
788                parts.push(default_val);
789            }
790            Some(v) => {
791                // Flush deferred nil placeholders for skipped optional args that precede this one.
792                for _ in 0..skipped_optional_count {
793                    parts.push("nil".to_string());
794                }
795                skipped_optional_count = 0;
796                // For json_object args with options_type, construct a typed options object.
797                // When result_is_simple, the binding accepts a plain Hash (no wrapper class).
798                if arg.arg_type == "json_object" && !v.is_null() {
799                    if let (Some(opts_type), Some(obj)) = (options_type, v.as_object()) {
800                        let kwargs: Vec<String> = obj
801                            .iter()
802                            .map(|(k, vv)| {
803                                let snake_key = k.to_snake_case();
804                                let rb_val = if enum_fields.contains_key(k) {
805                                    if let Some(s) = vv.as_str() {
806                                        let snake_val = s.to_snake_case();
807                                        format!("'{snake_val}'")
808                                    } else {
809                                        json_to_ruby(vv)
810                                    }
811                                } else {
812                                    json_to_ruby(vv)
813                                };
814                                format!("{snake_key}: {rb_val}")
815                            })
816                            .collect();
817                        if result_is_simple {
818                            parts.push(format!("{{{}}}", kwargs.join(", ")));
819                        } else {
820                            parts.push(format!("{opts_type}.new({})", kwargs.join(", ")));
821                        }
822                        continue;
823                    }
824                }
825                parts.push(json_to_ruby(v));
826            }
827        }
828    }
829
830    (setup_lines, parts.join(", "))
831}
832
833fn render_assertion(
834    out: &mut String,
835    assertion: &Assertion,
836    result_var: &str,
837    field_resolver: &FieldResolver,
838    result_is_simple: bool,
839    e2e_config: &E2eConfig,
840) {
841    // Handle synthetic / derived fields before the is_valid_for_result check
842    // so they are never treated as struct attribute accesses on the result.
843    if let Some(f) = &assertion.field {
844        match f.as_str() {
845            "chunks_have_content" => {
846                let pred = format!("({result_var}.chunks || []).all? {{ |c| c.content && !c.content.empty? }}");
847                match assertion.assertion_type.as_str() {
848                    "is_true" => {
849                        let _ = writeln!(out, "    expect({pred}).to be(true)");
850                    }
851                    "is_false" => {
852                        let _ = writeln!(out, "    expect({pred}).to be(false)");
853                    }
854                    _ => {
855                        let _ = writeln!(
856                            out,
857                            "    # skipped: unsupported assertion type on synthetic field '{f}'"
858                        );
859                    }
860                }
861                return;
862            }
863            "chunks_have_embeddings" => {
864                let pred =
865                    format!("({result_var}.chunks || []).all? {{ |c| !c.embedding.nil? && !c.embedding.empty? }}");
866                match assertion.assertion_type.as_str() {
867                    "is_true" => {
868                        let _ = writeln!(out, "    expect({pred}).to be(true)");
869                    }
870                    "is_false" => {
871                        let _ = writeln!(out, "    expect({pred}).to be(false)");
872                    }
873                    _ => {
874                        let _ = writeln!(
875                            out,
876                            "    # skipped: unsupported assertion type on synthetic field '{f}'"
877                        );
878                    }
879                }
880                return;
881            }
882            // ---- EmbedResponse virtual fields ----
883            // embed_texts returns Array<Array<Float>> in Ruby — no wrapper struct.
884            // result_var is the embedding matrix; use it directly.
885            "embeddings" => {
886                match assertion.assertion_type.as_str() {
887                    "count_equals" => {
888                        if let Some(val) = &assertion.value {
889                            let rb_val = json_to_ruby(val);
890                            let _ = writeln!(out, "    expect({result_var}.length).to eq({rb_val})");
891                        }
892                    }
893                    "count_min" => {
894                        if let Some(val) = &assertion.value {
895                            let rb_val = json_to_ruby(val);
896                            let _ = writeln!(out, "    expect({result_var}.length).to be >= {rb_val}");
897                        }
898                    }
899                    "not_empty" => {
900                        let _ = writeln!(out, "    expect({result_var}).not_to be_empty");
901                    }
902                    "is_empty" => {
903                        let _ = writeln!(out, "    expect({result_var}).to be_empty");
904                    }
905                    _ => {
906                        let _ = writeln!(
907                            out,
908                            "    # skipped: unsupported assertion type on synthetic field 'embeddings'"
909                        );
910                    }
911                }
912                return;
913            }
914            "embedding_dimensions" => {
915                let expr = format!("({result_var}.empty? ? 0 : {result_var}[0].length)");
916                match assertion.assertion_type.as_str() {
917                    "equals" => {
918                        if let Some(val) = &assertion.value {
919                            let rb_val = json_to_ruby(val);
920                            let _ = writeln!(out, "    expect({expr}).to eq({rb_val})");
921                        }
922                    }
923                    "greater_than" => {
924                        if let Some(val) = &assertion.value {
925                            let rb_val = json_to_ruby(val);
926                            let _ = writeln!(out, "    expect({expr}).to be > {rb_val}");
927                        }
928                    }
929                    _ => {
930                        let _ = writeln!(
931                            out,
932                            "    # skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
933                        );
934                    }
935                }
936                return;
937            }
938            "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
939                let pred = match f.as_str() {
940                    "embeddings_valid" => {
941                        format!("{result_var}.all? {{ |e| !e.empty? }}")
942                    }
943                    "embeddings_finite" => {
944                        format!("{result_var}.all? {{ |e| e.all? {{ |v| v.finite? }} }}")
945                    }
946                    "embeddings_non_zero" => {
947                        format!("{result_var}.all? {{ |e| e.any? {{ |v| v != 0.0 }} }}")
948                    }
949                    "embeddings_normalized" => {
950                        format!("{result_var}.all? {{ |e| n = e.sum {{ |v| v * v }}; (n - 1.0).abs < 1e-3 }}")
951                    }
952                    _ => unreachable!(),
953                };
954                match assertion.assertion_type.as_str() {
955                    "is_true" => {
956                        let _ = writeln!(out, "    expect({pred}).to be(true)");
957                    }
958                    "is_false" => {
959                        let _ = writeln!(out, "    expect({pred}).to be(false)");
960                    }
961                    _ => {
962                        let _ = writeln!(
963                            out,
964                            "    # skipped: unsupported assertion type on synthetic field '{f}'"
965                        );
966                    }
967                }
968                return;
969            }
970            // ---- keywords / keywords_count ----
971            // Ruby ExtractionResult does not expose extracted_keywords; skip.
972            "keywords" | "keywords_count" => {
973                let _ = writeln!(out, "    # skipped: field '{f}' not available on Ruby ExtractionResult");
974                return;
975            }
976            _ => {}
977        }
978    }
979
980    // Skip assertions on fields that don't exist on the result type.
981    if let Some(f) = &assertion.field {
982        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
983            let _ = writeln!(out, "    # skipped: field '{f}' not available on result type");
984            return;
985        }
986    }
987
988    // When result_is_simple, skip assertions that reference non-content fields.
989    if result_is_simple {
990        if let Some(f) = &assertion.field {
991            let f_lower = f.to_lowercase();
992            if !f.is_empty()
993                && f_lower != "content"
994                && (f_lower.starts_with("metadata")
995                    || f_lower.starts_with("document")
996                    || f_lower.starts_with("structure"))
997            {
998                return;
999            }
1000        }
1001    }
1002
1003    let field_expr = if result_is_simple {
1004        result_var.to_string()
1005    } else {
1006        match &assertion.field {
1007            Some(f) if !f.is_empty() => field_resolver.accessor(f, "ruby", result_var),
1008            _ => result_var.to_string(),
1009        }
1010    };
1011
1012    // For string equality, strip trailing whitespace to handle trailing newlines
1013    // from the converter.
1014    let stripped_field_expr = if result_is_simple {
1015        format!("{field_expr}.strip")
1016    } else {
1017        field_expr.clone()
1018    };
1019
1020    match assertion.assertion_type.as_str() {
1021        "equals" => {
1022            if let Some(expected) = &assertion.value {
1023                // Use be(true)/be(false) for booleans (RSpec/BeEq).
1024                if let Some(b) = expected.as_bool() {
1025                    let _ = writeln!(out, "    expect({stripped_field_expr}).to be({b})");
1026                } else {
1027                    let rb_val = json_to_ruby(expected);
1028                    let _ = writeln!(out, "    expect({stripped_field_expr}).to eq({rb_val})");
1029                }
1030            }
1031        }
1032        "contains" => {
1033            if let Some(expected) = &assertion.value {
1034                let rb_val = json_to_ruby(expected);
1035                // Use .to_s to handle both String and Symbol (enum) fields
1036                let _ = writeln!(out, "    expect({field_expr}.to_s).to include({rb_val})");
1037            }
1038        }
1039        "contains_all" => {
1040            if let Some(values) = &assertion.values {
1041                for val in values {
1042                    let rb_val = json_to_ruby(val);
1043                    let _ = writeln!(out, "    expect({field_expr}.to_s).to include({rb_val})");
1044                }
1045            }
1046        }
1047        "not_contains" => {
1048            if let Some(expected) = &assertion.value {
1049                let rb_val = json_to_ruby(expected);
1050                let _ = writeln!(out, "    expect({field_expr}.to_s).not_to include({rb_val})");
1051            }
1052        }
1053        "not_empty" => {
1054            let _ = writeln!(out, "    expect({field_expr}).not_to be_empty");
1055        }
1056        "is_empty" => {
1057            // Handle nil (None) as empty for optional fields
1058            let _ = writeln!(out, "    expect({field_expr}.nil? || {field_expr}.empty?).to be(true)");
1059        }
1060        "contains_any" => {
1061            if let Some(values) = &assertion.values {
1062                let items: Vec<String> = values.iter().map(json_to_ruby).collect();
1063                let arr_str = items.join(", ");
1064                let _ = writeln!(
1065                    out,
1066                    "    expect([{arr_str}].any? {{ |v| {field_expr}.to_s.include?(v) }}).to be(true)"
1067                );
1068            }
1069        }
1070        "greater_than" => {
1071            if let Some(val) = &assertion.value {
1072                let rb_val = json_to_ruby(val);
1073                let _ = writeln!(out, "    expect({field_expr}).to be > {rb_val}");
1074            }
1075        }
1076        "less_than" => {
1077            if let Some(val) = &assertion.value {
1078                let rb_val = json_to_ruby(val);
1079                let _ = writeln!(out, "    expect({field_expr}).to be < {rb_val}");
1080            }
1081        }
1082        "greater_than_or_equal" => {
1083            if let Some(val) = &assertion.value {
1084                let rb_val = json_to_ruby(val);
1085                let _ = writeln!(out, "    expect({field_expr}).to be >= {rb_val}");
1086            }
1087        }
1088        "less_than_or_equal" => {
1089            if let Some(val) = &assertion.value {
1090                let rb_val = json_to_ruby(val);
1091                let _ = writeln!(out, "    expect({field_expr}).to be <= {rb_val}");
1092            }
1093        }
1094        "starts_with" => {
1095            if let Some(expected) = &assertion.value {
1096                let rb_val = json_to_ruby(expected);
1097                let _ = writeln!(out, "    expect({field_expr}).to start_with({rb_val})");
1098            }
1099        }
1100        "ends_with" => {
1101            if let Some(expected) = &assertion.value {
1102                let rb_val = json_to_ruby(expected);
1103                let _ = writeln!(out, "    expect({field_expr}).to end_with({rb_val})");
1104            }
1105        }
1106        "min_length" => {
1107            if let Some(val) = &assertion.value {
1108                if let Some(n) = val.as_u64() {
1109                    let _ = writeln!(out, "    expect({field_expr}.length).to be >= {n}");
1110                }
1111            }
1112        }
1113        "max_length" => {
1114            if let Some(val) = &assertion.value {
1115                if let Some(n) = val.as_u64() {
1116                    let _ = writeln!(out, "    expect({field_expr}.length).to be <= {n}");
1117                }
1118            }
1119        }
1120        "count_min" => {
1121            if let Some(val) = &assertion.value {
1122                if let Some(n) = val.as_u64() {
1123                    let _ = writeln!(out, "    expect({field_expr}.length).to be >= {n}");
1124                }
1125            }
1126        }
1127        "count_equals" => {
1128            if let Some(val) = &assertion.value {
1129                if let Some(n) = val.as_u64() {
1130                    let _ = writeln!(out, "    expect({field_expr}.length).to eq({n})");
1131                }
1132            }
1133        }
1134        "is_true" => {
1135            let _ = writeln!(out, "    expect({field_expr}).to be true");
1136        }
1137        "is_false" => {
1138            let _ = writeln!(out, "    expect({field_expr}).to be false");
1139        }
1140        "method_result" => {
1141            if let Some(method_name) = &assertion.method {
1142                // Derive call_receiver for module-level helper calls.
1143                let lang = "ruby";
1144                let call = &e2e_config.call;
1145                let overrides = call.overrides.get(lang);
1146                let module_path = overrides
1147                    .and_then(|o| o.module.as_ref())
1148                    .cloned()
1149                    .unwrap_or_else(|| call.module.clone());
1150                let call_receiver = ruby_module_name(&module_path);
1151
1152                let call_expr =
1153                    build_ruby_method_call(&call_receiver, result_var, method_name, assertion.args.as_ref());
1154                let check = assertion.check.as_deref().unwrap_or("is_true");
1155                match check {
1156                    "equals" => {
1157                        if let Some(val) = &assertion.value {
1158                            if let Some(b) = val.as_bool() {
1159                                let _ = writeln!(out, "    expect({call_expr}).to be {b}");
1160                            } else {
1161                                let rb_val = json_to_ruby(val);
1162                                let _ = writeln!(out, "    expect({call_expr}).to eq({rb_val})");
1163                            }
1164                        }
1165                    }
1166                    "is_true" => {
1167                        let _ = writeln!(out, "    expect({call_expr}).to be true");
1168                    }
1169                    "is_false" => {
1170                        let _ = writeln!(out, "    expect({call_expr}).to be false");
1171                    }
1172                    "greater_than_or_equal" => {
1173                        if let Some(val) = &assertion.value {
1174                            let rb_val = json_to_ruby(val);
1175                            let _ = writeln!(out, "    expect({call_expr}).to be >= {rb_val}");
1176                        }
1177                    }
1178                    "count_min" => {
1179                        if let Some(val) = &assertion.value {
1180                            let n = val.as_u64().unwrap_or(0);
1181                            let _ = writeln!(out, "    expect({call_expr}.length).to be >= {n}");
1182                        }
1183                    }
1184                    "is_error" => {
1185                        let _ = writeln!(out, "    expect {{ {call_expr} }}.to raise_error");
1186                    }
1187                    "contains" => {
1188                        if let Some(val) = &assertion.value {
1189                            let rb_val = json_to_ruby(val);
1190                            let _ = writeln!(out, "    expect({call_expr}).to include({rb_val})");
1191                        }
1192                    }
1193                    other_check => {
1194                        panic!("Ruby e2e generator: unsupported method_result check type: {other_check}");
1195                    }
1196                }
1197            } else {
1198                panic!("Ruby e2e generator: method_result assertion missing 'method' field");
1199            }
1200        }
1201        "matches_regex" => {
1202            if let Some(expected) = &assertion.value {
1203                let rb_val = json_to_ruby(expected);
1204                let _ = writeln!(out, "    expect({field_expr}).to match({rb_val})");
1205            }
1206        }
1207        "not_error" => {
1208            // Already handled by the call succeeding without exception.
1209        }
1210        "error" => {
1211            // Handled at the example level.
1212        }
1213        other => {
1214            panic!("Ruby e2e generator: unsupported assertion type: {other}");
1215        }
1216    }
1217}
1218
1219/// Build a Ruby call expression for a `method_result` assertion on a tree-sitter Tree.
1220/// Maps method names to the appropriate Ruby method or module-function calls.
1221fn build_ruby_method_call(
1222    call_receiver: &str,
1223    result_var: &str,
1224    method_name: &str,
1225    args: Option<&serde_json::Value>,
1226) -> String {
1227    match method_name {
1228        "root_child_count" => format!("{result_var}.root_node.child_count"),
1229        "root_node_type" => format!("{result_var}.root_node.type"),
1230        "named_children_count" => format!("{result_var}.root_node.named_child_count"),
1231        "has_error_nodes" => format!("{call_receiver}.tree_has_error_nodes({result_var})"),
1232        "error_count" | "tree_error_count" => format!("{call_receiver}.tree_error_count({result_var})"),
1233        "tree_to_sexp" => format!("{call_receiver}.tree_to_sexp({result_var})"),
1234        "contains_node_type" => {
1235            let node_type = args
1236                .and_then(|a| a.get("node_type"))
1237                .and_then(|v| v.as_str())
1238                .unwrap_or("");
1239            format!("{call_receiver}.tree_contains_node_type({result_var}, \"{node_type}\")")
1240        }
1241        "find_nodes_by_type" => {
1242            let node_type = args
1243                .and_then(|a| a.get("node_type"))
1244                .and_then(|v| v.as_str())
1245                .unwrap_or("");
1246            format!("{call_receiver}.find_nodes_by_type({result_var}, \"{node_type}\")")
1247        }
1248        "run_query" => {
1249            let query_source = args
1250                .and_then(|a| a.get("query_source"))
1251                .and_then(|v| v.as_str())
1252                .unwrap_or("");
1253            let language = args
1254                .and_then(|a| a.get("language"))
1255                .and_then(|v| v.as_str())
1256                .unwrap_or("");
1257            format!("{call_receiver}.run_query({result_var}, \"{language}\", \"{query_source}\", source)")
1258        }
1259        _ => format!("{result_var}.{method_name}"),
1260    }
1261}
1262
1263/// Convert a module path (e.g., "html_to_markdown") to Ruby PascalCase module name
1264/// (e.g., "HtmlToMarkdown").
1265fn ruby_module_name(module_path: &str) -> String {
1266    use heck::ToUpperCamelCase;
1267    module_path.to_upper_camel_case()
1268}
1269
1270/// Convert a `serde_json::Value` to a Ruby literal string, preferring single quotes.
1271fn json_to_ruby(value: &serde_json::Value) -> String {
1272    match value {
1273        serde_json::Value::String(s) => ruby_string_literal(s),
1274        serde_json::Value::Bool(true) => "true".to_string(),
1275        serde_json::Value::Bool(false) => "false".to_string(),
1276        serde_json::Value::Number(n) => n.to_string(),
1277        serde_json::Value::Null => "nil".to_string(),
1278        serde_json::Value::Array(arr) => {
1279            let items: Vec<String> = arr.iter().map(json_to_ruby).collect();
1280            format!("[{}]", items.join(", "))
1281        }
1282        serde_json::Value::Object(map) => {
1283            let items: Vec<String> = map
1284                .iter()
1285                .map(|(k, v)| format!("{} => {}", ruby_string_literal(k), json_to_ruby(v)))
1286                .collect();
1287            format!("{{ {} }}", items.join(", "))
1288        }
1289    }
1290}
1291
1292// ---------------------------------------------------------------------------
1293// Visitor generation
1294// ---------------------------------------------------------------------------
1295
1296/// Build a Ruby visitor object and add setup lines. Returns the visitor expression.
1297fn build_ruby_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1298    setup_lines.push("visitor = Class.new do".to_string());
1299    for (method_name, action) in &visitor_spec.callbacks {
1300        emit_ruby_visitor_method(setup_lines, method_name, action);
1301    }
1302    setup_lines.push("end.new".to_string());
1303    "visitor".to_string()
1304}
1305
1306/// Emit a Ruby visitor method for a callback action.
1307fn emit_ruby_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
1308    let snake_method = method_name;
1309    let params = match method_name {
1310        "visit_link" => "ctx, href, text, title",
1311        "visit_image" => "ctx, src, alt, title",
1312        "visit_heading" => "ctx, level, text, id",
1313        "visit_code_block" => "ctx, lang, code",
1314        "visit_code_inline"
1315        | "visit_strong"
1316        | "visit_emphasis"
1317        | "visit_strikethrough"
1318        | "visit_underline"
1319        | "visit_subscript"
1320        | "visit_superscript"
1321        | "visit_mark"
1322        | "visit_button"
1323        | "visit_summary"
1324        | "visit_figcaption"
1325        | "visit_definition_term"
1326        | "visit_definition_description" => "ctx, text",
1327        "visit_text" => "ctx, text",
1328        "visit_list_item" => "ctx, ordered, marker, text",
1329        "visit_blockquote" => "ctx, content, depth",
1330        "visit_table_row" => "ctx, cells, is_header",
1331        "visit_custom_element" => "ctx, tag_name, html",
1332        "visit_form" => "ctx, action_url, method",
1333        "visit_input" => "ctx, input_type, name, value",
1334        "visit_audio" | "visit_video" | "visit_iframe" => "ctx, src",
1335        "visit_details" => "ctx, is_open",
1336        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "ctx, output",
1337        "visit_list_start" => "ctx, ordered",
1338        "visit_list_end" => "ctx, ordered, output",
1339        _ => "ctx",
1340    };
1341
1342    setup_lines.push(format!("  def {snake_method}({params})"));
1343    match action {
1344        CallbackAction::Skip => {
1345            setup_lines.push("    'skip'".to_string());
1346        }
1347        CallbackAction::Continue => {
1348            setup_lines.push("    'continue'".to_string());
1349        }
1350        CallbackAction::PreserveHtml => {
1351            setup_lines.push("    'preserve_html'".to_string());
1352        }
1353        CallbackAction::Custom { output } => {
1354            let escaped = ruby_string_literal(output);
1355            setup_lines.push(format!("    {{ custom: {escaped} }}"));
1356        }
1357        CallbackAction::CustomTemplate { template } => {
1358            let escaped = ruby_string_literal(template);
1359            setup_lines.push(format!("    {{ custom: {escaped} }}"));
1360        }
1361    }
1362    setup_lines.push("  end".to_string());
1363}