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