Skip to main content

alef_e2e/codegen/
go.rs

1//! Go e2e test generator using testing.T.
2
3use crate::config::E2eConfig;
4use crate::escape::{go_string_literal, sanitize_filename};
5use crate::field_access::FieldResolver;
6use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup};
7use alef_codegen::naming::{go_param_name, to_go_name};
8use alef_core::backend::GeneratedFile;
9use alef_core::config::AlefConfig;
10use alef_core::hash::{self, CommentStyle};
11use anyhow::Result;
12use heck::ToUpperCamelCase;
13use std::fmt::Write as FmtWrite;
14use std::path::PathBuf;
15
16use super::E2eCodegen;
17
18/// Go e2e code generator.
19pub struct GoCodegen;
20
21impl E2eCodegen for GoCodegen {
22    fn generate(
23        &self,
24        groups: &[FixtureGroup],
25        e2e_config: &E2eConfig,
26        alef_config: &AlefConfig,
27    ) -> Result<Vec<GeneratedFile>> {
28        let lang = self.language_name();
29        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
30
31        let mut files = Vec::new();
32
33        // Resolve call config with overrides (for module path and import alias).
34        let call = &e2e_config.call;
35        let overrides = call.overrides.get(lang);
36        let module_path = overrides
37            .and_then(|o| o.module.as_ref())
38            .cloned()
39            .unwrap_or_else(|| call.module.clone());
40        let import_alias = overrides
41            .and_then(|o| o.alias.as_ref())
42            .cloned()
43            .unwrap_or_else(|| "pkg".to_string());
44
45        // Resolve package config.
46        let go_pkg = e2e_config.resolve_package("go");
47        let go_module_path = go_pkg
48            .as_ref()
49            .and_then(|p| p.module.as_ref())
50            .cloned()
51            .unwrap_or_else(|| module_path.clone());
52        let replace_path = go_pkg.as_ref().and_then(|p| p.path.as_ref()).cloned();
53        let go_version = go_pkg
54            .as_ref()
55            .and_then(|p| p.version.as_ref())
56            .cloned()
57            .unwrap_or_else(|| {
58                alef_config
59                    .resolved_version()
60                    .map(|v| format!("v{v}"))
61                    .unwrap_or_else(|| "v0.0.0".to_string())
62            });
63        let field_resolver = FieldResolver::new(
64            &e2e_config.fields,
65            &e2e_config.fields_optional,
66            &e2e_config.result_fields,
67            &e2e_config.fields_array,
68        );
69
70        // Generate go.mod. In registry mode, omit the `replace` directive so the
71        // module is fetched from the Go module proxy.
72        let effective_replace = match e2e_config.dep_mode {
73            crate::config::DependencyMode::Registry => None,
74            crate::config::DependencyMode::Local => replace_path.as_deref().map(String::from),
75        };
76        files.push(GeneratedFile {
77            path: output_base.join("go.mod"),
78            content: render_go_mod(&go_module_path, effective_replace.as_deref(), &go_version),
79            generated_header: false,
80        });
81
82        // Generate test files per category.
83        for group in groups {
84            let active: Vec<&Fixture> = group
85                .fixtures
86                .iter()
87                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
88                .collect();
89
90            if active.is_empty() {
91                continue;
92            }
93
94            let filename = format!("{}_test.go", sanitize_filename(&group.category));
95            let content = render_test_file(
96                &group.category,
97                &active,
98                &module_path,
99                &import_alias,
100                &field_resolver,
101                e2e_config,
102            );
103            files.push(GeneratedFile {
104                path: output_base.join(filename),
105                content,
106                generated_header: true,
107            });
108        }
109
110        Ok(files)
111    }
112
113    fn language_name(&self) -> &'static str {
114        "go"
115    }
116}
117
118fn render_go_mod(go_module_path: &str, replace_path: Option<&str>, version: &str) -> String {
119    let mut out = String::new();
120    let _ = writeln!(out, "module e2e_go");
121    let _ = writeln!(out);
122    let _ = writeln!(out, "go 1.26");
123    let _ = writeln!(out);
124    let _ = writeln!(out, "require {go_module_path} {version}");
125
126    if let Some(path) = replace_path {
127        let _ = writeln!(out);
128        let _ = writeln!(out, "replace {go_module_path} => {path}");
129    }
130
131    out
132}
133
134fn render_test_file(
135    category: &str,
136    fixtures: &[&Fixture],
137    go_module_path: &str,
138    import_alias: &str,
139    field_resolver: &FieldResolver,
140    e2e_config: &crate::config::E2eConfig,
141) -> String {
142    let mut out = String::new();
143
144    // Go convention: generated file marker must appear before the package declaration.
145    out.push_str(&hash::header(CommentStyle::DoubleSlash));
146    let _ = writeln!(out);
147
148    // Determine if we need the "os" import (mock_url args).
149    // Check all resolved per-fixture call args.
150    let needs_os = fixtures.iter().any(|f| {
151        let call_args = &e2e_config.resolve_call(f.call.as_deref()).args;
152        call_args.iter().any(|a| a.arg_type == "mock_url")
153    });
154
155    // Determine if we need "encoding/json" (handle args with non-null config).
156    let needs_json = fixtures.iter().any(|f| {
157        let call_args = &e2e_config.resolve_call(f.call.as_deref()).args;
158        call_args.iter().any(|a| a.arg_type == "handle") && {
159            call_args.iter().filter(|a| a.arg_type == "handle").any(|a| {
160                let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
161                !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
162            })
163        }
164    });
165
166    // Determine if we need the "fmt" import (CustomTemplate visitor actions with placeholders).
167    let needs_fmt = fixtures.iter().any(|f| {
168        f.visitor.as_ref().is_some_and(|v| {
169            v.callbacks.values().any(|action| {
170                if let CallbackAction::CustomTemplate { template } = action {
171                    template.contains('{')
172                } else {
173                    false
174                }
175            })
176        })
177    });
178
179    // Determine if we need the "strings" import.
180    // Only count assertions whose fields are actually valid for the result type.
181    let needs_strings = fixtures.iter().any(|f| {
182        f.assertions.iter().any(|a| {
183            let type_needs_strings = if a.assertion_type == "equals" {
184                // equals with string values needs strings.TrimSpace
185                a.value.as_ref().is_some_and(|v| v.is_string())
186            } else {
187                matches!(
188                    a.assertion_type.as_str(),
189                    "contains" | "contains_all" | "not_contains" | "starts_with" | "ends_with"
190                )
191            };
192            let field_valid = a
193                .field
194                .as_ref()
195                .map(|f| f.is_empty() || field_resolver.is_valid_for_result(f))
196                .unwrap_or(true);
197            type_needs_strings && field_valid
198        })
199    });
200
201    // Determine if we need the testify assert import (used for count_min, count_max,
202    // is_true, is_false, and method_result assertions).
203    let needs_assert = fixtures.iter().any(|f| {
204        f.assertions.iter().any(|a| {
205            let field_valid = a
206                .field
207                .as_ref()
208                .map(|f| f.is_empty() || field_resolver.is_valid_for_result(f))
209                .unwrap_or(true);
210            let type_needs_assert = matches!(
211                a.assertion_type.as_str(),
212                "count_min"
213                    | "count_max"
214                    | "is_true"
215                    | "is_false"
216                    | "method_result"
217                    | "min_length"
218                    | "max_length"
219                    | "matches_regex"
220            );
221            type_needs_assert && field_valid
222        })
223    });
224
225    let _ = writeln!(out, "// E2e tests for category: {category}");
226    let _ = writeln!(out, "package e2e_test");
227    let _ = writeln!(out);
228    let _ = writeln!(out, "import (");
229    if needs_json {
230        let _ = writeln!(out, "\t\"encoding/json\"");
231    }
232    if needs_fmt {
233        let _ = writeln!(out, "\t\"fmt\"");
234    }
235    if needs_os {
236        let _ = writeln!(out, "\t\"os\"");
237    }
238    if needs_strings {
239        let _ = writeln!(out, "\t\"strings\"");
240    }
241    let _ = writeln!(out, "\t\"testing\"");
242    if needs_assert {
243        let _ = writeln!(out);
244        let _ = writeln!(out, "\t\"github.com/stretchr/testify/assert\"");
245    }
246    let _ = writeln!(out);
247    let _ = writeln!(out, "\t{import_alias} \"{go_module_path}\"");
248    let _ = writeln!(out, ")");
249    let _ = writeln!(out);
250
251    // Emit package-level visitor structs (must be outside any function in Go).
252    for fixture in fixtures.iter() {
253        if let Some(visitor_spec) = &fixture.visitor {
254            let struct_name = visitor_struct_name(&fixture.id);
255            emit_go_visitor_struct(&mut out, &struct_name, visitor_spec, import_alias);
256            let _ = writeln!(out);
257        }
258    }
259
260    for (i, fixture) in fixtures.iter().enumerate() {
261        render_test_function(&mut out, fixture, import_alias, field_resolver, e2e_config);
262        if i + 1 < fixtures.len() {
263            let _ = writeln!(out);
264        }
265    }
266
267    // Clean up trailing newlines.
268    while out.ends_with("\n\n") {
269        out.pop();
270    }
271    if !out.ends_with('\n') {
272        out.push('\n');
273    }
274    out
275}
276
277fn render_test_function(
278    out: &mut String,
279    fixture: &Fixture,
280    import_alias: &str,
281    field_resolver: &FieldResolver,
282    e2e_config: &crate::config::E2eConfig,
283) {
284    let fn_name = fixture.id.to_upper_camel_case();
285    let description = &fixture.description;
286
287    // Resolve call config per-fixture (supports named calls via fixture.call).
288    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
289    let lang = "go";
290    let overrides = call_config.overrides.get(lang);
291    let function_name = to_go_name(
292        overrides
293            .and_then(|o| o.function.as_ref())
294            .map(String::as_str)
295            .unwrap_or(&call_config.function),
296    );
297    let result_var = &call_config.result_var;
298    let args = &call_config.args;
299
300    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
301
302    let (mut setup_lines, args_str) = build_args_and_setup(&fixture.input, args, import_alias, e2e_config, &fixture.id);
303
304    // Build visitor if present — struct is at package level, just instantiate here.
305    let mut visitor_arg = String::new();
306    if fixture.visitor.is_some() {
307        let struct_name = visitor_struct_name(&fixture.id);
308        setup_lines.push(format!("visitor := &{struct_name}{{}}"));
309        visitor_arg = "visitor".to_string();
310    }
311
312    let final_args = if visitor_arg.is_empty() {
313        args_str
314    } else {
315        format!("{args_str}, {visitor_arg}")
316    };
317
318    let _ = writeln!(out, "func Test_{fn_name}(t *testing.T) {{");
319    let _ = writeln!(out, "\t// {description}");
320
321    for line in &setup_lines {
322        let _ = writeln!(out, "\t{line}");
323    }
324
325    if expects_error {
326        let _ = writeln!(out, "\t_, err := {import_alias}.{function_name}({final_args})");
327        let _ = writeln!(out, "\tif err == nil {{");
328        let _ = writeln!(out, "\t\tt.Errorf(\"expected an error, but call succeeded\")");
329        let _ = writeln!(out, "\t}}");
330        let _ = writeln!(out, "}}");
331        return;
332    }
333
334    // Check if any assertion actually uses the result variable.
335    // If all assertions are skipped (field not on result type), use `_` to avoid
336    // Go's "declared and not used" compile error.
337    let has_usable_assertion = fixture.assertions.iter().any(|a| {
338        if a.assertion_type == "not_error" || a.assertion_type == "error" {
339            return false;
340        }
341        // method_result assertions always use the result variable.
342        if a.assertion_type == "method_result" {
343            return true;
344        }
345        match &a.field {
346            Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
347            _ => true,
348        }
349    });
350
351    let result_binding = if has_usable_assertion {
352        result_var.to_string()
353    } else {
354        "_".to_string()
355    };
356
357    // Normal call: check for error assertions first.
358    let _ = writeln!(
359        out,
360        "\t{result_binding}, err := {import_alias}.{function_name}({final_args})"
361    );
362    let _ = writeln!(out, "\tif err != nil {{");
363    let _ = writeln!(out, "\t\tt.Fatalf(\"call failed: %v\", err)");
364    let _ = writeln!(out, "\t}}");
365
366    // Collect optional fields referenced by assertions and emit nil-safe
367    // dereference blocks so that assertions can use plain string locals.
368    // Only dereference fields whose assertion values are strings (or that are
369    // used in string-oriented assertions like equals/contains with string values).
370    let mut optional_locals: std::collections::HashMap<String, String> = std::collections::HashMap::new();
371    for assertion in &fixture.assertions {
372        if let Some(f) = &assertion.field {
373            if !f.is_empty() {
374                let resolved = field_resolver.resolve(f);
375                if field_resolver.is_optional(resolved) && !optional_locals.contains_key(f.as_str()) {
376                    // Only create deref locals for string-valued fields.
377                    // Detect by checking if the assertion value is a string.
378                    let is_string_field = assertion.value.as_ref().is_some_and(|v| v.is_string());
379                    if !is_string_field {
380                        // Non-string optional fields (e.g., *uint64) are handled
381                        // by nil guards in render_assertion instead.
382                        continue;
383                    }
384                    let field_expr = field_resolver.accessor(f, "go", result_var);
385                    let local_var = go_param_name(&resolved.replace(['.', '[', ']'], "_"));
386                    if field_resolver.has_map_access(f) {
387                        // Go map access returns a value type (string), not a pointer.
388                        // Use the value directly — empty string means not present.
389                        let _ = writeln!(out, "\t{local_var} := {field_expr}");
390                    } else {
391                        let _ = writeln!(out, "\tvar {local_var} string");
392                        let _ = writeln!(out, "\tif {field_expr} != nil {{");
393                        let _ = writeln!(out, "\t\t{local_var} = *{field_expr}");
394                        let _ = writeln!(out, "\t}}");
395                    }
396                    optional_locals.insert(f.clone(), local_var);
397                }
398            }
399        }
400    }
401
402    // Emit assertions, wrapping in nil guards when an intermediate path segment is optional.
403    for assertion in &fixture.assertions {
404        if let Some(f) = &assertion.field {
405            if !f.is_empty() && !optional_locals.contains_key(f.as_str()) {
406                // Check if any prefix of the dotted path is optional (pointer in Go).
407                // e.g., "document.nodes" — if "document" is optional, guard the whole block.
408                let parts: Vec<&str> = f.split('.').collect();
409                let mut guard_expr: Option<String> = None;
410                for i in 1..parts.len() {
411                    let prefix = parts[..i].join(".");
412                    let resolved_prefix = field_resolver.resolve(&prefix);
413                    if field_resolver.is_optional(resolved_prefix) {
414                        let accessor = field_resolver.accessor(&prefix, "go", result_var);
415                        guard_expr = Some(accessor);
416                        break;
417                    }
418                }
419                if let Some(guard) = guard_expr {
420                    // Only emit nil guard if the assertion will actually produce code
421                    // (not just a skip comment), to avoid empty branches (SA9003).
422                    if field_resolver.is_valid_for_result(f) {
423                        let _ = writeln!(out, "\tif {guard} != nil {{");
424                        // Render into a temporary buffer so we can re-indent by one
425                        // tab level to sit inside the nil-guard block.
426                        let mut nil_buf = String::new();
427                        render_assertion(
428                            &mut nil_buf,
429                            assertion,
430                            result_var,
431                            import_alias,
432                            field_resolver,
433                            &optional_locals,
434                        );
435                        for line in nil_buf.lines() {
436                            let _ = writeln!(out, "\t{line}");
437                        }
438                        let _ = writeln!(out, "\t}}");
439                    } else {
440                        render_assertion(
441                            out,
442                            assertion,
443                            result_var,
444                            import_alias,
445                            field_resolver,
446                            &optional_locals,
447                        );
448                    }
449                    continue;
450                }
451            }
452        }
453        render_assertion(
454            out,
455            assertion,
456            result_var,
457            import_alias,
458            field_resolver,
459            &optional_locals,
460        );
461    }
462
463    let _ = writeln!(out, "}}");
464}
465
466/// Build setup lines (e.g. handle creation) and the argument list for the function call.
467///
468/// Returns `(setup_lines, args_string)`.
469fn build_args_and_setup(
470    input: &serde_json::Value,
471    args: &[crate::config::ArgMapping],
472    import_alias: &str,
473    e2e_config: &crate::config::E2eConfig,
474    fixture_id: &str,
475) -> (Vec<String>, String) {
476    use heck::ToUpperCamelCase;
477
478    if args.is_empty() {
479        return (Vec::new(), json_to_go(input));
480    }
481
482    let overrides = e2e_config.call.overrides.get("go");
483    let options_type = overrides.and_then(|o| o.options_type.as_deref());
484
485    let mut setup_lines: Vec<String> = Vec::new();
486    let mut parts: Vec<String> = Vec::new();
487
488    for arg in args {
489        if arg.arg_type == "mock_url" {
490            setup_lines.push(format!(
491                "{} := os.Getenv(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\"",
492                arg.name,
493            ));
494            parts.push(arg.name.clone());
495            continue;
496        }
497
498        if arg.arg_type == "handle" {
499            // Generate a CreateEngine (or equivalent) call and pass the variable.
500            let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
501            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
502            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
503            if config_value.is_null()
504                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
505            {
506                setup_lines.push(format!(
507                    "{name}, createErr := {import_alias}.{constructor_name}(nil)\n\tif createErr != nil {{\n\t\tt.Fatalf(\"create handle failed: %v\", createErr)\n\t}}",
508                    name = arg.name,
509                ));
510            } else {
511                let json_str = serde_json::to_string(config_value).unwrap_or_default();
512                let go_literal = go_string_literal(&json_str);
513                let name = &arg.name;
514                setup_lines.push(format!(
515                    "var {name}Config {import_alias}.CrawlConfig\n\tif err := json.Unmarshal([]byte({go_literal}), &{name}Config); err != nil {{\n\t\tt.Fatalf(\"config parse failed: %v\", err)\n\t}}"
516                ));
517                setup_lines.push(format!(
518                    "{name}, createErr := {import_alias}.{constructor_name}(&{name}Config)\n\tif createErr != nil {{\n\t\tt.Fatalf(\"create handle failed: %v\", createErr)\n\t}}"
519                ));
520            }
521            parts.push(arg.name.clone());
522            continue;
523        }
524
525        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
526        let val = input.get(field);
527        match val {
528            None | Some(serde_json::Value::Null) if arg.optional => {
529                // Optional arg with no fixture value: skip entirely.
530                continue;
531            }
532            None | Some(serde_json::Value::Null) => {
533                // Required arg with no fixture value: pass a language-appropriate default.
534                let default_val = match arg.arg_type.as_str() {
535                    "string" => "\"\"".to_string(),
536                    "int" | "integer" => "0".to_string(),
537                    "float" | "number" => "0.0".to_string(),
538                    "bool" | "boolean" => "false".to_string(),
539                    _ => "nil".to_string(),
540                };
541                parts.push(default_val);
542            }
543            Some(v) => {
544                // For json_object args with options_type: construct using functional options.
545                if let (Some(opts_type), "json_object") = (options_type, arg.arg_type.as_str()) {
546                    if let Some(obj) = v.as_object() {
547                        let with_calls: Vec<String> = obj
548                            .iter()
549                            .map(|(k, vv)| {
550                                let func_name = format!("With{}{}", opts_type, k.to_upper_camel_case());
551                                let go_val = json_to_go(vv);
552                                format!("htmd.{func_name}({go_val})")
553                            })
554                            .collect();
555                        let new_fn = format!("New{opts_type}");
556                        parts.push(format!("htmd.{new_fn}({})", with_calls.join(", ")));
557                        continue;
558                    }
559                }
560                parts.push(json_to_go(v));
561            }
562        }
563    }
564
565    (setup_lines, parts.join(", "))
566}
567
568fn render_assertion(
569    out: &mut String,
570    assertion: &Assertion,
571    result_var: &str,
572    import_alias: &str,
573    field_resolver: &FieldResolver,
574    optional_locals: &std::collections::HashMap<String, String>,
575) {
576    // Skip assertions on fields that don't exist on the result type.
577    if let Some(f) = &assertion.field {
578        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
579            let _ = writeln!(out, "\t// skipped: field '{f}' not available on result type");
580            return;
581        }
582    }
583
584    let field_expr = match &assertion.field {
585        Some(f) if !f.is_empty() => {
586            // Use the local variable if the field was dereferenced above.
587            if let Some(local_var) = optional_locals.get(f.as_str()) {
588                local_var.clone()
589            } else {
590                field_resolver.accessor(f, "go", result_var)
591            }
592        }
593        _ => result_var.to_string(),
594    };
595
596    // Check if the field (after resolution) is optional, which means it's a pointer in Go.
597    // Also check if a `.length` suffix's parent is optional (e.g., metadata.headings.length
598    // where metadata.headings is optional → len() needs dereference).
599    let is_optional = assertion
600        .field
601        .as_ref()
602        .map(|f| {
603            let resolved = field_resolver.resolve(f);
604            let check_path = resolved
605                .strip_suffix(".length")
606                .or_else(|| resolved.strip_suffix(".count"))
607                .or_else(|| resolved.strip_suffix(".size"))
608                .unwrap_or(resolved);
609            field_resolver.is_optional(check_path) && !optional_locals.contains_key(f.as_str())
610        })
611        .unwrap_or(false);
612
613    // When field_expr is `len(X)` and X is an optional (pointer) field, rewrite to `len(*X)`
614    // and we'll wrap with a nil guard in the assertion handlers.
615    let field_expr = if is_optional && field_expr.starts_with("len(") && field_expr.ends_with(')') {
616        let inner = &field_expr[4..field_expr.len() - 1];
617        format!("len(*{inner})")
618    } else {
619        field_expr
620    };
621    // Build the nil-guard expression for the inner pointer (without len wrapper).
622    let nil_guard_expr = if is_optional && field_expr.starts_with("len(*") {
623        Some(field_expr[5..field_expr.len() - 1].to_string())
624    } else {
625        None
626    };
627
628    // For optional non-string fields that weren't dereferenced into locals,
629    // we need to dereference the pointer in comparisons.
630    let deref_field_expr = if is_optional && !field_expr.starts_with("len(") {
631        format!("*{field_expr}")
632    } else {
633        field_expr.clone()
634    };
635
636    // Detect array element access (e.g., `result.Assets[0].ContentHash`).
637    // When the field_expr contains `[0]`, we must guard against an out-of-bounds
638    // panic by checking that the array is non-empty first.
639    // Extract the array slice expression (everything before `[0]`).
640    let array_guard: Option<String> = if let Some(idx) = field_expr.find("[0]") {
641        let array_expr = &field_expr[..idx];
642        Some(array_expr.to_string())
643    } else {
644        None
645    };
646
647    // Render the assertion into a temporary buffer first, then wrap with the array
648    // bounds guard (if needed) by adding one extra level of indentation.
649    let mut assertion_buf = String::new();
650    let out_ref = &mut assertion_buf;
651
652    match assertion.assertion_type.as_str() {
653        "equals" => {
654            if let Some(expected) = &assertion.value {
655                let go_val = json_to_go(expected);
656                // For string equality, trim whitespace to handle trailing newlines from the converter.
657                if expected.is_string() {
658                    // Wrap field expression with strings.TrimSpace() for string comparisons.
659                    let trimmed_field = if is_optional && !field_expr.starts_with("len(") {
660                        format!("strings.TrimSpace(*{field_expr})")
661                    } else {
662                        format!("strings.TrimSpace({field_expr})")
663                    };
664                    if is_optional && !field_expr.starts_with("len(") {
665                        let _ = writeln!(out_ref, "\tif {field_expr} != nil && {trimmed_field} != {go_val} {{");
666                    } else {
667                        let _ = writeln!(out_ref, "\tif {trimmed_field} != {go_val} {{");
668                    }
669                } else if is_optional && !field_expr.starts_with("len(") {
670                    let _ = writeln!(out_ref, "\tif {field_expr} != nil && {deref_field_expr} != {go_val} {{");
671                } else {
672                    let _ = writeln!(out_ref, "\tif {field_expr} != {go_val} {{");
673                }
674                let _ = writeln!(out_ref, "\t\tt.Errorf(\"equals mismatch: got %v\", {field_expr})");
675                let _ = writeln!(out_ref, "\t}}");
676            }
677        }
678        "contains" => {
679            if let Some(expected) = &assertion.value {
680                let go_val = json_to_go(expected);
681                let field_for_contains = if is_optional
682                    && !optional_locals.contains_key(assertion.field.as_ref().unwrap_or(&String::new()))
683                {
684                    format!("string(*{field_expr})")
685                } else {
686                    format!("string({field_expr})")
687                };
688                let _ = writeln!(out_ref, "\tif !strings.Contains({field_for_contains}, {go_val}) {{");
689                let _ = writeln!(
690                    out_ref,
691                    "\t\tt.Errorf(\"expected to contain %s, got %v\", {go_val}, {field_expr})"
692                );
693                let _ = writeln!(out_ref, "\t}}");
694            }
695        }
696        "contains_all" => {
697            if let Some(values) = &assertion.values {
698                for val in values {
699                    let go_val = json_to_go(val);
700                    let field_for_contains = if is_optional
701                        && !optional_locals.contains_key(assertion.field.as_ref().unwrap_or(&String::new()))
702                    {
703                        format!("string(*{field_expr})")
704                    } else {
705                        format!("string({field_expr})")
706                    };
707                    let _ = writeln!(out_ref, "\tif !strings.Contains({field_for_contains}, {go_val}) {{");
708                    let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected to contain %s\", {go_val})");
709                    let _ = writeln!(out_ref, "\t}}");
710                }
711            }
712        }
713        "not_contains" => {
714            if let Some(expected) = &assertion.value {
715                let go_val = json_to_go(expected);
716                let field_for_contains = if is_optional
717                    && !optional_locals.contains_key(assertion.field.as_ref().unwrap_or(&String::new()))
718                {
719                    format!("string(*{field_expr})")
720                } else {
721                    format!("string({field_expr})")
722                };
723                let _ = writeln!(out_ref, "\tif strings.Contains({field_for_contains}, {go_val}) {{");
724                let _ = writeln!(
725                    out_ref,
726                    "\t\tt.Errorf(\"expected NOT to contain %s, got %v\", {go_val}, {field_expr})"
727                );
728                let _ = writeln!(out_ref, "\t}}");
729            }
730        }
731        "not_empty" => {
732            if is_optional {
733                let _ = writeln!(out_ref, "\tif {field_expr} == nil || len(*{field_expr}) == 0 {{");
734            } else {
735                let _ = writeln!(out_ref, "\tif len({field_expr}) == 0 {{");
736            }
737            let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected non-empty value\")");
738            let _ = writeln!(out_ref, "\t}}");
739        }
740        "is_empty" => {
741            if is_optional {
742                let _ = writeln!(out_ref, "\tif {field_expr} != nil && len(*{field_expr}) != 0 {{");
743            } else {
744                let _ = writeln!(out_ref, "\tif len({field_expr}) != 0 {{");
745            }
746            let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected empty value, got %v\", {field_expr})");
747            let _ = writeln!(out_ref, "\t}}");
748        }
749        "contains_any" => {
750            if let Some(values) = &assertion.values {
751                let field_for_contains = if is_optional
752                    && !optional_locals.contains_key(assertion.field.as_ref().unwrap_or(&String::new()))
753                {
754                    format!("*{field_expr}")
755                } else {
756                    field_expr.clone()
757                };
758                let _ = writeln!(out_ref, "\t{{");
759                let _ = writeln!(out_ref, "\t\tfound := false");
760                for val in values {
761                    let go_val = json_to_go(val);
762                    let _ = writeln!(
763                        out_ref,
764                        "\t\tif strings.Contains({field_for_contains}, {go_val}) {{ found = true }}"
765                    );
766                }
767                let _ = writeln!(out_ref, "\t\tif !found {{");
768                let _ = writeln!(
769                    out_ref,
770                    "\t\t\tt.Errorf(\"expected to contain at least one of the specified values\")"
771                );
772                let _ = writeln!(out_ref, "\t\t}}");
773                let _ = writeln!(out_ref, "\t}}");
774            }
775        }
776        "greater_than" => {
777            if let Some(val) = &assertion.value {
778                let go_val = json_to_go(val);
779                // Use `< N+1` instead of `<= N` to avoid golangci-lint sloppyLen
780                // warning when N is 0 (len(x) <= 0 → len(x) < 1).
781                if let Some(n) = val.as_u64() {
782                    let next = n + 1;
783                    let _ = writeln!(out_ref, "\tif {field_expr} < {next} {{");
784                } else {
785                    let _ = writeln!(out_ref, "\tif {field_expr} <= {go_val} {{");
786                }
787                let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected > {go_val}, got %v\", {field_expr})");
788                let _ = writeln!(out_ref, "\t}}");
789            }
790        }
791        "less_than" => {
792            if let Some(val) = &assertion.value {
793                let go_val = json_to_go(val);
794                let _ = writeln!(out_ref, "\tif {field_expr} >= {go_val} {{");
795                let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected < {go_val}, got %v\", {field_expr})");
796                let _ = writeln!(out_ref, "\t}}");
797            }
798        }
799        "greater_than_or_equal" => {
800            if let Some(val) = &assertion.value {
801                let go_val = json_to_go(val);
802                if let Some(ref guard) = nil_guard_expr {
803                    let _ = writeln!(out_ref, "\tif {guard} != nil {{");
804                    let _ = writeln!(out_ref, "\t\tif {field_expr} < {go_val} {{");
805                    let _ = writeln!(
806                        out_ref,
807                        "\t\t\tt.Errorf(\"expected >= {go_val}, got %v\", {field_expr})"
808                    );
809                    let _ = writeln!(out_ref, "\t\t}}");
810                    let _ = writeln!(out_ref, "\t}}");
811                } else {
812                    let _ = writeln!(out_ref, "\tif {field_expr} < {go_val} {{");
813                    let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected >= {go_val}, got %v\", {field_expr})");
814                    let _ = writeln!(out_ref, "\t}}");
815                }
816            }
817        }
818        "less_than_or_equal" => {
819            if let Some(val) = &assertion.value {
820                let go_val = json_to_go(val);
821                let _ = writeln!(out_ref, "\tif {field_expr} > {go_val} {{");
822                let _ = writeln!(out_ref, "\t\tt.Errorf(\"expected <= {go_val}, got %v\", {field_expr})");
823                let _ = writeln!(out_ref, "\t}}");
824            }
825        }
826        "starts_with" => {
827            if let Some(expected) = &assertion.value {
828                let go_val = json_to_go(expected);
829                let field_for_prefix = if is_optional
830                    && !optional_locals.contains_key(assertion.field.as_ref().unwrap_or(&String::new()))
831                {
832                    format!("string(*{field_expr})")
833                } else {
834                    format!("string({field_expr})")
835                };
836                let _ = writeln!(out_ref, "\tif !strings.HasPrefix({field_for_prefix}, {go_val}) {{");
837                let _ = writeln!(
838                    out_ref,
839                    "\t\tt.Errorf(\"expected to start with %s, got %v\", {go_val}, {field_expr})"
840                );
841                let _ = writeln!(out_ref, "\t}}");
842            }
843        }
844        "count_min" => {
845            if let Some(val) = &assertion.value {
846                if let Some(n) = val.as_u64() {
847                    if is_optional {
848                        let _ = writeln!(out_ref, "\tif {field_expr} != nil {{");
849                        let _ = writeln!(
850                            out_ref,
851                            "\t\tassert.GreaterOrEqual(t, len(*{field_expr}), {n}, \"expected at least {n} elements\")"
852                        );
853                        let _ = writeln!(out_ref, "\t}}");
854                    } else {
855                        let _ = writeln!(
856                            out_ref,
857                            "\tassert.GreaterOrEqual(t, len({field_expr}), {n}, \"expected at least {n} elements\")"
858                        );
859                    }
860                }
861            }
862        }
863        "count_equals" => {
864            if let Some(val) = &assertion.value {
865                if let Some(n) = val.as_u64() {
866                    if is_optional {
867                        let _ = writeln!(out_ref, "\tif {field_expr} != nil {{");
868                        let _ = writeln!(
869                            out_ref,
870                            "\t\tassert.Equal(t, len(*{field_expr}), {n}, \"expected exactly {n} elements\")"
871                        );
872                        let _ = writeln!(out_ref, "\t}}");
873                    } else {
874                        let _ = writeln!(
875                            out_ref,
876                            "\tassert.Equal(t, len({field_expr}), {n}, \"expected exactly {n} elements\")"
877                        );
878                    }
879                }
880            }
881        }
882        "is_true" => {
883            if is_optional {
884                let _ = writeln!(out_ref, "\tif {field_expr} != nil {{");
885                let _ = writeln!(out_ref, "\t\tassert.True(t, *{field_expr}, \"expected true\")");
886                let _ = writeln!(out_ref, "\t}}");
887            } else {
888                let _ = writeln!(out_ref, "\tassert.True(t, {field_expr}, \"expected true\")");
889            }
890        }
891        "is_false" => {
892            if is_optional {
893                let _ = writeln!(out_ref, "\tif {field_expr} != nil {{");
894                let _ = writeln!(out_ref, "\t\tassert.False(t, *{field_expr}, \"expected false\")");
895                let _ = writeln!(out_ref, "\t}}");
896            } else {
897                let _ = writeln!(out_ref, "\tassert.False(t, {field_expr}, \"expected false\")");
898            }
899        }
900        "method_result" => {
901            if let Some(method_name) = &assertion.method {
902                let info = build_go_method_call(result_var, method_name, assertion.args.as_ref(), import_alias);
903                let check = assertion.check.as_deref().unwrap_or("is_true");
904                // For pointer-returning functions, dereference with `*`. Value-returning
905                // functions (e.g., NodeInfo field access) are used directly.
906                let deref_expr = if info.is_pointer {
907                    format!("*{}", info.call_expr)
908                } else {
909                    info.call_expr.clone()
910                };
911                match check {
912                    "equals" => {
913                        if let Some(val) = &assertion.value {
914                            if val.is_boolean() {
915                                if val.as_bool() == Some(true) {
916                                    let _ = writeln!(out_ref, "\tassert.True(t, {deref_expr}, \"expected true\")");
917                                } else {
918                                    let _ = writeln!(out_ref, "\tassert.False(t, {deref_expr}, \"expected false\")");
919                                }
920                            } else {
921                                // Apply type cast to numeric literals when the method returns
922                                // a typed uint (e.g., *uint) to avoid reflect.DeepEqual
923                                // mismatches between int and uint in testify's assert.Equal.
924                                let go_val = if let Some(cast) = info.value_cast {
925                                    if val.is_number() {
926                                        format!("{cast}({})", json_to_go(val))
927                                    } else {
928                                        json_to_go(val)
929                                    }
930                                } else {
931                                    json_to_go(val)
932                                };
933                                let _ = writeln!(
934                                    out_ref,
935                                    "\tassert.Equal(t, {go_val}, {deref_expr}, \"method_result equals assertion failed\")"
936                                );
937                            }
938                        }
939                    }
940                    "is_true" => {
941                        let _ = writeln!(out_ref, "\tassert.True(t, {deref_expr}, \"expected true\")");
942                    }
943                    "is_false" => {
944                        let _ = writeln!(out_ref, "\tassert.False(t, {deref_expr}, \"expected false\")");
945                    }
946                    "greater_than_or_equal" => {
947                        if let Some(val) = &assertion.value {
948                            let n = val.as_u64().unwrap_or(0);
949                            // Use the value_cast type if available (e.g., uint for named_children_count).
950                            let cast = info.value_cast.unwrap_or("uint");
951                            let _ = writeln!(
952                                out_ref,
953                                "\tassert.GreaterOrEqual(t, {deref_expr}, {cast}({n}), \"expected >= {n}\")"
954                            );
955                        }
956                    }
957                    "count_min" => {
958                        if let Some(val) = &assertion.value {
959                            let n = val.as_u64().unwrap_or(0);
960                            let _ = writeln!(
961                                out_ref,
962                                "\tassert.GreaterOrEqual(t, len({deref_expr}), {n}, \"expected at least {n} elements\")"
963                            );
964                        }
965                    }
966                    "contains" => {
967                        if let Some(val) = &assertion.value {
968                            let go_val = json_to_go(val);
969                            let _ = writeln!(
970                                out_ref,
971                                "\tassert.Contains(t, {deref_expr}, {go_val}, \"expected result to contain value\")"
972                            );
973                        }
974                    }
975                    "is_error" => {
976                        let _ = writeln!(out_ref, "\t{{");
977                        let _ = writeln!(out_ref, "\t\t_, methodErr := {}", info.call_expr);
978                        let _ = writeln!(out_ref, "\t\tassert.Error(t, methodErr)");
979                        let _ = writeln!(out_ref, "\t}}");
980                    }
981                    other_check => {
982                        panic!("Go e2e generator: unsupported method_result check type: {other_check}");
983                    }
984                }
985            } else {
986                panic!("Go e2e generator: method_result assertion missing 'method' field");
987            }
988        }
989        "min_length" => {
990            if let Some(val) = &assertion.value {
991                if let Some(n) = val.as_u64() {
992                    if is_optional {
993                        let _ = writeln!(out_ref, "\tif {field_expr} != nil {{");
994                        let _ = writeln!(
995                            out_ref,
996                            "\t\tassert.GreaterOrEqual(t, len(*{field_expr}), {n}, \"expected length >= {n}\")"
997                        );
998                        let _ = writeln!(out_ref, "\t}}");
999                    } else {
1000                        let _ = writeln!(
1001                            out_ref,
1002                            "\tassert.GreaterOrEqual(t, len({field_expr}), {n}, \"expected length >= {n}\")"
1003                        );
1004                    }
1005                }
1006            }
1007        }
1008        "max_length" => {
1009            if let Some(val) = &assertion.value {
1010                if let Some(n) = val.as_u64() {
1011                    if is_optional {
1012                        let _ = writeln!(out_ref, "\tif {field_expr} != nil {{");
1013                        let _ = writeln!(
1014                            out_ref,
1015                            "\t\tassert.LessOrEqual(t, len(*{field_expr}), {n}, \"expected length <= {n}\")"
1016                        );
1017                        let _ = writeln!(out_ref, "\t}}");
1018                    } else {
1019                        let _ = writeln!(
1020                            out_ref,
1021                            "\tassert.LessOrEqual(t, len({field_expr}), {n}, \"expected length <= {n}\")"
1022                        );
1023                    }
1024                }
1025            }
1026        }
1027        "ends_with" => {
1028            if let Some(expected) = &assertion.value {
1029                let go_val = json_to_go(expected);
1030                let field_for_suffix = if is_optional
1031                    && !optional_locals.contains_key(assertion.field.as_ref().unwrap_or(&String::new()))
1032                {
1033                    format!("string(*{field_expr})")
1034                } else {
1035                    format!("string({field_expr})")
1036                };
1037                let _ = writeln!(out_ref, "\tif !strings.HasSuffix({field_for_suffix}, {go_val}) {{");
1038                let _ = writeln!(
1039                    out_ref,
1040                    "\t\tt.Errorf(\"expected to end with %s, got %v\", {go_val}, {field_expr})"
1041                );
1042                let _ = writeln!(out_ref, "\t}}");
1043            }
1044        }
1045        "matches_regex" => {
1046            if let Some(expected) = &assertion.value {
1047                let go_val = json_to_go(expected);
1048                let field_for_regex = if is_optional
1049                    && !optional_locals.contains_key(assertion.field.as_ref().unwrap_or(&String::new()))
1050                {
1051                    format!("*{field_expr}")
1052                } else {
1053                    field_expr.clone()
1054                };
1055                let _ = writeln!(
1056                    out_ref,
1057                    "\tassert.Regexp(t, {go_val}, {field_for_regex}, \"expected value to match regex\")"
1058                );
1059            }
1060        }
1061        "not_error" => {
1062            // Already handled by the `if err != nil` check above.
1063        }
1064        "error" => {
1065            // Handled at the test function level.
1066        }
1067        other => {
1068            panic!("Go e2e generator: unsupported assertion type: {other}");
1069        }
1070    }
1071
1072    // If the assertion accesses an array element via [0], wrap the generated code in a
1073    // bounds check to prevent an index-out-of-range panic when the array is empty.
1074    if let Some(ref arr) = array_guard {
1075        if !assertion_buf.is_empty() {
1076            let _ = writeln!(out, "\tif len({arr}) > 0 {{");
1077            // Re-indent each line by one additional tab level.
1078            for line in assertion_buf.lines() {
1079                let _ = writeln!(out, "\t{line}");
1080            }
1081            let _ = writeln!(out, "\t}}");
1082        }
1083    } else {
1084        out.push_str(&assertion_buf);
1085    }
1086}
1087
1088/// Metadata about the return type of a Go method call for `method_result` assertions.
1089struct GoMethodCallInfo {
1090    /// The call expression string.
1091    call_expr: String,
1092    /// Whether the return type is a pointer (needs `*` dereference for value comparison).
1093    is_pointer: bool,
1094    /// Optional Go type cast to apply to numeric literal values in `equals` assertions
1095    /// (e.g., `"uint"` so that `0` becomes `uint(0)` to match `*uint` deref type).
1096    value_cast: Option<&'static str>,
1097}
1098
1099/// Build a Go call expression for a `method_result` assertion on a tree-sitter Tree.
1100///
1101/// Maps method names to the appropriate Go function calls, matching the Go binding API
1102/// in `packages/go/binding.go`. Returns a [`GoMethodCallInfo`] describing the call and
1103/// its return type characteristics.
1104///
1105/// Return types by method:
1106/// - `has_error_nodes`, `contains_node_type` → `*bool` (pointer)
1107/// - `error_count` → `*uint` (pointer, value_cast = "uint")
1108/// - `tree_to_sexp` → `*string` (pointer)
1109/// - `root_node_type` → `string` via `RootNodeInfo(tree).Kind` (value)
1110/// - `named_children_count` → `uint` via `RootNodeInfo(tree).NamedChildCount` (value, value_cast = "uint")
1111/// - `find_nodes_by_type` → `*[]NodeInfo` (pointer to slice)
1112/// - `run_query` → `(*[]QueryMatch, error)` (pointer + error; use `is_error` check type)
1113fn build_go_method_call(
1114    result_var: &str,
1115    method_name: &str,
1116    args: Option<&serde_json::Value>,
1117    import_alias: &str,
1118) -> GoMethodCallInfo {
1119    match method_name {
1120        "root_node_type" => GoMethodCallInfo {
1121            call_expr: format!("{import_alias}.RootNodeInfo({result_var}).Kind"),
1122            is_pointer: false,
1123            value_cast: None,
1124        },
1125        "named_children_count" => GoMethodCallInfo {
1126            call_expr: format!("{import_alias}.RootNodeInfo({result_var}).NamedChildCount"),
1127            is_pointer: false,
1128            value_cast: Some("uint"),
1129        },
1130        "has_error_nodes" => GoMethodCallInfo {
1131            call_expr: format!("{import_alias}.TreeHasErrorNodes({result_var})"),
1132            is_pointer: true,
1133            value_cast: None,
1134        },
1135        "error_count" | "tree_error_count" => GoMethodCallInfo {
1136            call_expr: format!("{import_alias}.TreeErrorCount({result_var})"),
1137            is_pointer: true,
1138            value_cast: Some("uint"),
1139        },
1140        "tree_to_sexp" => GoMethodCallInfo {
1141            call_expr: format!("{import_alias}.TreeToSexp({result_var})"),
1142            is_pointer: true,
1143            value_cast: None,
1144        },
1145        "contains_node_type" => {
1146            let node_type = args
1147                .and_then(|a| a.get("node_type"))
1148                .and_then(|v| v.as_str())
1149                .unwrap_or("");
1150            GoMethodCallInfo {
1151                call_expr: format!("{import_alias}.TreeContainsNodeType({result_var}, \"{node_type}\")"),
1152                is_pointer: true,
1153                value_cast: None,
1154            }
1155        }
1156        "find_nodes_by_type" => {
1157            let node_type = args
1158                .and_then(|a| a.get("node_type"))
1159                .and_then(|v| v.as_str())
1160                .unwrap_or("");
1161            GoMethodCallInfo {
1162                call_expr: format!("{import_alias}.FindNodesByType({result_var}, \"{node_type}\")"),
1163                is_pointer: true,
1164                value_cast: None,
1165            }
1166        }
1167        "run_query" => {
1168            let query_source = args
1169                .and_then(|a| a.get("query_source"))
1170                .and_then(|v| v.as_str())
1171                .unwrap_or("");
1172            let language = args
1173                .and_then(|a| a.get("language"))
1174                .and_then(|v| v.as_str())
1175                .unwrap_or("");
1176            let query_lit = go_string_literal(query_source);
1177            let lang_lit = go_string_literal(language);
1178            // RunQuery returns (*[]QueryMatch, error) — use is_error check type.
1179            GoMethodCallInfo {
1180                call_expr: format!("{import_alias}.RunQuery({result_var}, {lang_lit}, {query_lit}, []byte(source))"),
1181                is_pointer: false,
1182                value_cast: None,
1183            }
1184        }
1185        other => {
1186            let method_pascal = other.to_upper_camel_case();
1187            GoMethodCallInfo {
1188                call_expr: format!("{result_var}.{method_pascal}()"),
1189                is_pointer: false,
1190                value_cast: None,
1191            }
1192        }
1193    }
1194}
1195
1196/// Convert a `serde_json::Value` to a Go literal string.
1197fn json_to_go(value: &serde_json::Value) -> String {
1198    match value {
1199        serde_json::Value::String(s) => go_string_literal(s),
1200        serde_json::Value::Bool(b) => b.to_string(),
1201        serde_json::Value::Number(n) => n.to_string(),
1202        serde_json::Value::Null => "nil".to_string(),
1203        // For complex types, serialize to JSON string and pass as literal.
1204        other => go_string_literal(&other.to_string()),
1205    }
1206}
1207
1208// ---------------------------------------------------------------------------
1209// Visitor generation
1210// ---------------------------------------------------------------------------
1211
1212/// Derive a unique, exported Go struct name for a visitor from a fixture ID.
1213///
1214/// E.g. `visitor_continue_default` → `visitorContinueDefault` (unexported, avoids
1215/// polluting the exported API of the test package while still being package-level).
1216fn visitor_struct_name(fixture_id: &str) -> String {
1217    use heck::ToUpperCamelCase;
1218    // Use UpperCamelCase so Go treats it as exported — required for method sets.
1219    format!("testVisitor{}", fixture_id.to_upper_camel_case())
1220}
1221
1222/// Emit a package-level Go struct declaration and all its visitor methods.
1223fn emit_go_visitor_struct(
1224    out: &mut String,
1225    struct_name: &str,
1226    visitor_spec: &crate::fixture::VisitorSpec,
1227    import_alias: &str,
1228) {
1229    let _ = writeln!(out, "type {struct_name} struct{{}}");
1230    for (method_name, action) in &visitor_spec.callbacks {
1231        emit_go_visitor_method(out, struct_name, method_name, action, import_alias);
1232    }
1233}
1234
1235/// Emit a Go visitor method for a callback action on the named struct.
1236fn emit_go_visitor_method(
1237    out: &mut String,
1238    struct_name: &str,
1239    method_name: &str,
1240    action: &CallbackAction,
1241    import_alias: &str,
1242) {
1243    let camel_method = method_to_camel(method_name);
1244    let params = match method_name {
1245        "visit_link" => format!("_ {import_alias}.NodeContext, href, text, title string"),
1246        "visit_image" => format!("_ {import_alias}.NodeContext, src, alt, title string"),
1247        "visit_heading" => format!("_ {import_alias}.NodeContext, level int, text, id string"),
1248        "visit_code_block" => format!("_ {import_alias}.NodeContext, lang, code string"),
1249        "visit_code_inline"
1250        | "visit_strong"
1251        | "visit_emphasis"
1252        | "visit_strikethrough"
1253        | "visit_underline"
1254        | "visit_subscript"
1255        | "visit_superscript"
1256        | "visit_mark"
1257        | "visit_button"
1258        | "visit_summary"
1259        | "visit_figcaption"
1260        | "visit_definition_term"
1261        | "visit_definition_description" => format!("_ {import_alias}.NodeContext, text string"),
1262        "visit_text" => format!("_ {import_alias}.NodeContext, text string"),
1263        "visit_list_item" => {
1264            format!("_ {import_alias}.NodeContext, ordered bool, marker, text string")
1265        }
1266        "visit_blockquote" => format!("_ {import_alias}.NodeContext, content string, depth int"),
1267        "visit_table_row" => format!("_ {import_alias}.NodeContext, cells []string, isHeader bool"),
1268        "visit_custom_element" => format!("_ {import_alias}.NodeContext, tagName, html string"),
1269        "visit_form" => format!("_ {import_alias}.NodeContext, actionUrl, method string"),
1270        "visit_input" => format!("_ {import_alias}.NodeContext, inputType, name, value string"),
1271        "visit_audio" | "visit_video" | "visit_iframe" => {
1272            format!("_ {import_alias}.NodeContext, src string")
1273        }
1274        "visit_details" => format!("_ {import_alias}.NodeContext, isOpen bool"),
1275        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
1276            format!("_ {import_alias}.NodeContext, output string")
1277        }
1278        "visit_list_start" => format!("_ {import_alias}.NodeContext, ordered bool"),
1279        "visit_list_end" => format!("_ {import_alias}.NodeContext, ordered bool, output string"),
1280        _ => format!("_ {import_alias}.NodeContext"),
1281    };
1282
1283    let _ = writeln!(
1284        out,
1285        "func (v *{struct_name}) {camel_method}({params}) {import_alias}.VisitResult {{"
1286    );
1287    match action {
1288        CallbackAction::Skip => {
1289            let _ = writeln!(out, "\treturn {import_alias}.VisitResultSkip");
1290        }
1291        CallbackAction::Continue => {
1292            let _ = writeln!(out, "\treturn {import_alias}.VisitResultContinue");
1293        }
1294        CallbackAction::PreserveHtml => {
1295            let _ = writeln!(out, "\treturn {import_alias}.VisitResultPreserveHtml");
1296        }
1297        CallbackAction::Custom { output } => {
1298            let escaped = go_string_literal(output);
1299            let _ = writeln!(out, "\treturn {import_alias}.VisitResultCustom({escaped})");
1300        }
1301        CallbackAction::CustomTemplate { template } => {
1302            // Convert {var} placeholders to %s format verbs and collect arg names.
1303            // E.g. `QUOTE: "{text}"` → fmt.Sprintf("QUOTE: \"%s\"", text)
1304            let (fmt_str, fmt_args) = template_to_sprintf(template);
1305            let escaped_fmt = go_string_literal(&fmt_str);
1306            if fmt_args.is_empty() {
1307                let _ = writeln!(out, "\treturn {import_alias}.VisitResultCustom({escaped_fmt})");
1308            } else {
1309                let args_str = fmt_args.join(", ");
1310                let _ = writeln!(
1311                    out,
1312                    "\treturn {import_alias}.VisitResultCustom(fmt.Sprintf({escaped_fmt}, {args_str}))"
1313                );
1314            }
1315        }
1316    }
1317    let _ = writeln!(out, "}}");
1318}
1319
1320/// Convert a `{var}` template string into a `fmt.Sprintf` format string and argument list.
1321///
1322/// For example, `QUOTE: "{text}"` becomes `("QUOTE: \"%s\"", vec!["text"])`.
1323fn template_to_sprintf(template: &str) -> (String, Vec<String>) {
1324    let mut fmt_str = String::new();
1325    let mut args: Vec<String> = Vec::new();
1326    let mut chars = template.chars().peekable();
1327    while let Some(c) = chars.next() {
1328        if c == '{' {
1329            // Collect placeholder name until '}'.
1330            let mut name = String::new();
1331            for inner in chars.by_ref() {
1332                if inner == '}' {
1333                    break;
1334                }
1335                name.push(inner);
1336            }
1337            fmt_str.push_str("%s");
1338            args.push(name);
1339        } else {
1340            fmt_str.push(c);
1341        }
1342    }
1343    (fmt_str, args)
1344}
1345
1346/// Convert snake_case method names to Go camelCase.
1347fn method_to_camel(snake: &str) -> String {
1348    use heck::ToUpperCamelCase;
1349    snake.to_upper_camel_case()
1350}
1351
1352#[cfg(test)]
1353mod tests {
1354    use super::*;
1355    use crate::config::{CallConfig, E2eConfig};
1356    use crate::field_access::FieldResolver;
1357    use crate::fixture::{Assertion, Fixture};
1358
1359    fn make_fixture(id: &str) -> Fixture {
1360        Fixture {
1361            id: id.to_string(),
1362            category: None,
1363            description: "test fixture".to_string(),
1364            tags: vec![],
1365            skip: None,
1366            call: None,
1367            input: serde_json::Value::Null,
1368            mock_response: None,
1369            source: String::new(),
1370            http: None,
1371            assertions: vec![Assertion {
1372                assertion_type: "not_error".to_string(),
1373                field: None,
1374                value: None,
1375                values: None,
1376                method: None,
1377                args: None,
1378                check: None,
1379            }],
1380            visitor: None,
1381        }
1382    }
1383
1384    /// snake_case function names in `[e2e.call]` must be routed through `to_go_name`
1385    /// so the emitted Go call uses the idiomatic CamelCase (e.g. `CleanExtractedText`
1386    /// instead of `clean_extracted_text`).
1387    #[test]
1388    fn test_go_method_name_uses_go_casing() {
1389        let e2e_config = E2eConfig {
1390            call: CallConfig {
1391                function: "clean_extracted_text".to_string(),
1392                module: "github.com/example/mylib".to_string(),
1393                result_var: "result".to_string(),
1394                r#async: false,
1395                path: None,
1396                method: None,
1397                args: vec![],
1398                overrides: std::collections::HashMap::new(),
1399                returns_result: true,
1400            },
1401            ..E2eConfig::default()
1402        };
1403
1404        let fixture = make_fixture("basic_text");
1405        let resolver = FieldResolver::new(
1406            &std::collections::HashMap::new(),
1407            &std::collections::HashSet::new(),
1408            &std::collections::HashSet::new(),
1409            &std::collections::HashSet::new(),
1410        );
1411        let mut out = String::new();
1412        render_test_function(&mut out, &fixture, "kreuzberg", &resolver, &e2e_config);
1413
1414        assert!(
1415            out.contains("kreuzberg.CleanExtractedText("),
1416            "expected Go-cased method name 'CleanExtractedText', got:\n{out}"
1417        );
1418        assert!(
1419            !out.contains("kreuzberg.clean_extracted_text("),
1420            "must not emit raw snake_case method name, got:\n{out}"
1421        );
1422    }
1423}