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