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