Skip to main content

alef_e2e/codegen/
typescript.rs

1//! TypeScript e2e test generator using vitest.
2
3use crate::config::E2eConfig;
4use crate::escape::{escape_js, sanitize_filename, sanitize_ident};
5use crate::field_access::FieldResolver;
6use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup};
7use alef_core::backend::GeneratedFile;
8use alef_core::config::AlefConfig;
9use anyhow::Result;
10use heck::ToUpperCamelCase;
11use std::fmt::Write as FmtWrite;
12use std::path::PathBuf;
13
14use super::E2eCodegen;
15
16/// TypeScript e2e code generator.
17pub struct TypeScriptCodegen;
18
19impl E2eCodegen for TypeScriptCodegen {
20    fn generate(
21        &self,
22        groups: &[FixtureGroup],
23        e2e_config: &E2eConfig,
24        _alef_config: &AlefConfig,
25    ) -> Result<Vec<GeneratedFile>> {
26        let output_base = PathBuf::from(e2e_config.effective_output()).join(self.language_name());
27        let tests_base = output_base.join("tests");
28
29        let mut files = Vec::new();
30
31        // Resolve call config with overrides — use "node" key (Language::Node).
32        let call = &e2e_config.call;
33        let overrides = call.overrides.get("node");
34        let module_path = overrides
35            .and_then(|o| o.module.as_ref())
36            .cloned()
37            .unwrap_or_else(|| call.module.clone());
38        let function_name = overrides
39            .and_then(|o| o.function.as_ref())
40            .cloned()
41            .unwrap_or_else(|| call.function.clone());
42        let result_var = &call.result_var;
43        let is_async = call.r#async;
44        let client_factory = overrides.and_then(|o| o.client_factory.as_deref());
45
46        // Resolve package config.
47        let node_pkg = e2e_config.resolve_package("node");
48        let pkg_path = node_pkg
49            .as_ref()
50            .and_then(|p| p.path.as_ref())
51            .cloned()
52            .unwrap_or_else(|| "../../packages/typescript".to_string());
53        let pkg_name = node_pkg
54            .as_ref()
55            .and_then(|p| p.name.as_ref())
56            .cloned()
57            .unwrap_or_else(|| module_path.clone());
58        let pkg_version = node_pkg
59            .as_ref()
60            .and_then(|p| p.version.as_ref())
61            .cloned()
62            .unwrap_or_else(|| "0.1.0".to_string());
63
64        // Generate package.json.
65        files.push(GeneratedFile {
66            path: output_base.join("package.json"),
67            content: render_package_json(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
68            generated_header: false,
69        });
70
71        // Generate tsconfig.json.
72        files.push(GeneratedFile {
73            path: output_base.join("tsconfig.json"),
74            content: render_tsconfig(),
75            generated_header: false,
76        });
77
78        // Generate vitest.config.ts — include globalSetup when client_factory is used.
79        files.push(GeneratedFile {
80            path: output_base.join("vitest.config.ts"),
81            content: render_vitest_config(client_factory.is_some()),
82            generated_header: true,
83        });
84
85        // Generate globalSetup.ts when client_factory is used (needs mock server).
86        if client_factory.is_some() {
87            files.push(GeneratedFile {
88                path: output_base.join("globalSetup.ts"),
89                content: render_global_setup(),
90                generated_header: true,
91            });
92        }
93
94        // Resolve options_type from override.
95        let options_type = overrides.and_then(|o| o.options_type.clone());
96        let field_resolver = FieldResolver::new(
97            &e2e_config.fields,
98            &e2e_config.fields_optional,
99            &e2e_config.result_fields,
100            &e2e_config.fields_array,
101        );
102
103        // Generate test files per category.
104        for group in groups {
105            let active: Vec<&Fixture> = group
106                .fixtures
107                .iter()
108                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip("node")))
109                .collect();
110
111            if active.is_empty() {
112                continue;
113            }
114
115            let filename = format!("{}.test.ts", sanitize_filename(&group.category));
116            let content = render_test_file(
117                &group.category,
118                &active,
119                &module_path,
120                &pkg_name,
121                &function_name,
122                result_var,
123                is_async,
124                &e2e_config.call.args,
125                options_type.as_deref(),
126                &field_resolver,
127                client_factory,
128            );
129            files.push(GeneratedFile {
130                path: tests_base.join(filename),
131                content,
132                generated_header: true,
133            });
134        }
135
136        Ok(files)
137    }
138
139    fn language_name(&self) -> &'static str {
140        "node"
141    }
142}
143
144fn render_package_json(
145    pkg_name: &str,
146    _pkg_path: &str,
147    pkg_version: &str,
148    dep_mode: crate::config::DependencyMode,
149) -> String {
150    let dep_value = match dep_mode {
151        crate::config::DependencyMode::Registry => pkg_version.to_string(),
152        crate::config::DependencyMode::Local => "workspace:*".to_string(),
153    };
154    format!(
155        r#"{{
156  "name": "{pkg_name}-e2e-typescript",
157  "version": "0.1.0",
158  "private": true,
159  "type": "module",
160  "scripts": {{
161    "test": "vitest run"
162  }},
163  "devDependencies": {{
164    "{pkg_name}": "{dep_value}",
165    "vitest": "^3.0.0"
166  }}
167}}
168"#
169    )
170}
171
172fn render_tsconfig() -> String {
173    r#"{
174  "compilerOptions": {
175    "target": "ES2022",
176    "module": "ESNext",
177    "moduleResolution": "bundler",
178    "strict": true,
179    "strictNullChecks": false,
180    "esModuleInterop": true,
181    "skipLibCheck": true
182  },
183  "include": ["tests/**/*.ts", "vitest.config.ts"]
184}
185"#
186    .to_string()
187}
188
189fn render_vitest_config(with_global_setup: bool) -> String {
190    if with_global_setup {
191        r#"// This file is auto-generated by alef. DO NOT EDIT.
192import { defineConfig } from 'vitest/config';
193
194export default defineConfig({
195  test: {
196    include: ['tests/**/*.test.ts'],
197    globalSetup: './globalSetup.ts',
198  },
199});
200"#
201        .to_string()
202    } else {
203        r#"// This file is auto-generated by alef. DO NOT EDIT.
204import { defineConfig } from 'vitest/config';
205
206export default defineConfig({
207  test: {
208    include: ['tests/**/*.test.ts'],
209  },
210});
211"#
212        .to_string()
213    }
214}
215
216fn render_global_setup() -> String {
217    r#"// This file is auto-generated by alef. DO NOT EDIT.
218import { spawn } from 'child_process';
219import { resolve } from 'path';
220
221let serverProcess;
222
223export async function setup() {
224  // Mock server binary must be pre-built (e.g. by CI or `cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release`)
225  serverProcess = spawn(
226    resolve(__dirname, '../rust/target/release/mock-server'),
227    [resolve(__dirname, '../../fixtures')],
228    { stdio: ['pipe', 'pipe', 'inherit'] }
229  );
230
231  const url = await new Promise((resolve, reject) => {
232    serverProcess.stdout.on('data', (data) => {
233      const match = data.toString().match(/MOCK_SERVER_URL=(.*)/);
234      if (match) resolve(match[1].trim());
235    });
236    setTimeout(() => reject(new Error('Mock server startup timeout')), 30000);
237  });
238
239  process.env.MOCK_SERVER_URL = url;
240}
241
242export async function teardown() {
243  if (serverProcess) {
244    serverProcess.stdin.end();
245    serverProcess.kill();
246  }
247}
248"#
249    .to_string()
250}
251
252#[allow(clippy::too_many_arguments)]
253fn render_test_file(
254    category: &str,
255    fixtures: &[&Fixture],
256    module_path: &str,
257    pkg_name: &str,
258    function_name: &str,
259    result_var: &str,
260    is_async: bool,
261    args: &[crate::config::ArgMapping],
262    options_type: Option<&str>,
263    field_resolver: &FieldResolver,
264    client_factory: Option<&str>,
265) -> String {
266    let mut out = String::new();
267    let _ = writeln!(out, "// This file is auto-generated by alef. DO NOT EDIT.");
268    let _ = writeln!(out, "import {{ describe, it, expect }} from 'vitest';");
269
270    // Check if any fixture uses a json_object arg that needs the options type import.
271    let needs_options_import = options_type.is_some()
272        && fixtures.iter().any(|f| {
273            args.iter().any(|arg| {
274                let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
275                let val = if field == "input" {
276                    Some(&f.input)
277                } else {
278                    f.input.get(field)
279                };
280                arg.arg_type == "json_object" && val.is_some_and(|v| !v.is_null())
281            })
282        });
283
284    // Collect handle constructor function names that need to be imported.
285    let handle_constructors: Vec<String> = args
286        .iter()
287        .filter(|arg| arg.arg_type == "handle")
288        .map(|arg| format!("create{}", arg.name.to_upper_camel_case()))
289        .collect();
290
291    // When using client_factory, import the factory instead of the function.
292    let mut imports: Vec<String> = if let Some(factory) = client_factory {
293        vec![factory.to_string()]
294    } else {
295        vec![function_name.to_string()]
296    };
297    for ctor in &handle_constructors {
298        if !imports.contains(ctor) {
299            imports.push(ctor.clone());
300        }
301    }
302
303    // Use pkg_name (the npm package name, e.g. "@kreuzberg/liter-llm") for
304    // the import specifier so that registry builds resolve the published package name.
305    let _ = module_path; // retained in signature for potential future use
306    if let (true, Some(opts_type)) = (needs_options_import, options_type) {
307        imports.push(format!("type {opts_type}"));
308        let imports_str = imports.join(", ");
309        let _ = writeln!(out, "import {{ {imports_str} }} from '{pkg_name}';");
310    } else {
311        let imports_str = imports.join(", ");
312        let _ = writeln!(out, "import {{ {imports_str} }} from '{pkg_name}';");
313    }
314    let _ = writeln!(out);
315    let _ = writeln!(out, "describe('{category}', () => {{");
316
317    for (i, fixture) in fixtures.iter().enumerate() {
318        render_test_case(
319            &mut out,
320            fixture,
321            function_name,
322            result_var,
323            client_factory,
324            is_async,
325            args,
326            options_type,
327            field_resolver,
328        );
329        if i + 1 < fixtures.len() {
330            let _ = writeln!(out);
331        }
332    }
333
334    let _ = writeln!(out, "}});");
335    out
336}
337
338#[allow(clippy::too_many_arguments)]
339fn render_test_case(
340    out: &mut String,
341    fixture: &Fixture,
342    function_name: &str,
343    result_var: &str,
344    client_factory: Option<&str>,
345    is_async: bool,
346    args: &[crate::config::ArgMapping],
347    options_type: Option<&str>,
348    field_resolver: &FieldResolver,
349) {
350    let test_name = sanitize_ident(&fixture.id);
351    let description = fixture.description.replace('\'', "\\'");
352    let async_kw = if is_async { "async " } else { "" };
353    let await_kw = if is_async { "await " } else { "" };
354
355    // Build the call expression — either `client.method(args)` or `method(args)`
356    let (mut setup_lines, args_str) = build_args_and_setup(&fixture.input, args, options_type, &fixture.id);
357
358    // Build visitor if present and add to setup
359    let mut visitor_arg = String::new();
360    if let Some(visitor_spec) = &fixture.visitor {
361        visitor_arg = build_typescript_visitor(&mut setup_lines, visitor_spec);
362    }
363
364    let final_args = if visitor_arg.is_empty() {
365        args_str
366    } else if args_str.is_empty() {
367        format!("{{ visitor: {visitor_arg} }}")
368    } else {
369        format!("{args_str}, {{ visitor: {visitor_arg} }}")
370    };
371
372    let call_expr = if client_factory.is_some() {
373        format!("client.{function_name}({final_args})")
374    } else {
375        format!("{function_name}({final_args})")
376    };
377
378    // Build the base_url expression for mock server
379    let base_url_expr = format!("`${{process.env.MOCK_SERVER_URL}}/fixtures/{}`", fixture.id);
380
381    // Check if this is an error-expecting test.
382    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
383
384    if expects_error {
385        let _ = writeln!(out, "  it('{test_name}: {description}', async () => {{");
386        if let Some(factory) = client_factory {
387            let _ = writeln!(out, "    const client = {factory}('test-key', {base_url_expr});");
388        }
389        // Wrap ALL setup lines and the function call inside the expect block so that
390        // synchronous throws from handle constructors (e.g. createEngine) are also caught.
391        let _ = writeln!(out, "    await expect(async () => {{");
392        for line in &setup_lines {
393            let _ = writeln!(out, "      {line}");
394        }
395        let _ = writeln!(out, "      await {call_expr};");
396        let _ = writeln!(out, "    }}).rejects.toThrow();");
397        let _ = writeln!(out, "  }});");
398        return;
399    }
400
401    let _ = writeln!(out, "  it('{test_name}: {description}', {async_kw}() => {{");
402
403    if let Some(factory) = client_factory {
404        let _ = writeln!(out, "    const client = {factory}('test-key', {base_url_expr});");
405    }
406
407    for line in &setup_lines {
408        let _ = writeln!(out, "    {line}");
409    }
410
411    // Check if any assertion actually uses the result variable.
412    let has_usable_assertion = fixture.assertions.iter().any(|a| {
413        if a.assertion_type == "not_error" || a.assertion_type == "error" {
414            return false;
415        }
416        match &a.field {
417            Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
418            _ => true,
419        }
420    });
421
422    if has_usable_assertion {
423        let _ = writeln!(out, "    const {result_var} = {await_kw}{call_expr};");
424    } else {
425        let _ = writeln!(out, "    {await_kw}{call_expr};");
426    }
427
428    // Emit assertions.
429    for assertion in &fixture.assertions {
430        render_assertion(out, assertion, result_var, field_resolver);
431    }
432
433    let _ = writeln!(out, "  }});");
434}
435
436/// Build setup lines (e.g. handle creation) and the argument list for the function call.
437///
438/// Returns `(setup_lines, args_string)`.
439fn build_args_and_setup(
440    input: &serde_json::Value,
441    args: &[crate::config::ArgMapping],
442    options_type: Option<&str>,
443    fixture_id: &str,
444) -> (Vec<String>, String) {
445    if args.is_empty() {
446        // If no args mapping, pass the whole input as a single argument.
447        return (Vec::new(), json_to_js(input));
448    }
449
450    let mut setup_lines: Vec<String> = Vec::new();
451    let mut parts: Vec<String> = Vec::new();
452
453    for arg in args {
454        if arg.arg_type == "mock_url" {
455            setup_lines.push(format!(
456                "const {} = `${{process.env.MOCK_SERVER_URL}}/fixtures/{fixture_id}`;",
457                arg.name,
458            ));
459            parts.push(arg.name.clone());
460            continue;
461        }
462
463        if arg.arg_type == "handle" {
464            // Generate a createEngine (or equivalent) call and pass the variable.
465            let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
466            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
467            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
468            if config_value.is_null()
469                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
470            {
471                setup_lines.push(format!("const {} = {constructor_name}(null);", arg.name));
472            } else {
473                // NAPI-RS bindings use camelCase for JS field names, so convert snake_case
474                // config keys from the fixture JSON to camelCase before passing to the constructor.
475                let literal = json_to_js_camel(config_value);
476                setup_lines.push(format!("const {name}Config = {literal};", name = arg.name,));
477                setup_lines.push(format!(
478                    "const {} = {constructor_name}({name}Config);",
479                    arg.name,
480                    name = arg.name,
481                ));
482            }
483            parts.push(arg.name.clone());
484            continue;
485        }
486
487        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
488        // When field == "input", the entire input object IS the value (not a nested key)
489        let val = if field == "input" {
490            Some(input)
491        } else {
492            input.get(field)
493        };
494        match val {
495            None | Some(serde_json::Value::Null) if arg.optional => {
496                // Optional arg with no fixture value: skip entirely.
497                continue;
498            }
499            None | Some(serde_json::Value::Null) => {
500                // Required arg with no fixture value: pass a language-appropriate default.
501                let default_val = match arg.arg_type.as_str() {
502                    "string" => "\"\"".to_string(),
503                    "int" | "integer" => "0".to_string(),
504                    "float" | "number" => "0.0".to_string(),
505                    "bool" | "boolean" => "false".to_string(),
506                    _ => "null".to_string(),
507                };
508                parts.push(default_val);
509            }
510            Some(v) => {
511                // For json_object args with options_type, cast the object literal.
512                if arg.arg_type == "json_object" {
513                    if let Some(opts_type) = options_type {
514                        parts.push(format!("{} as {opts_type}", json_to_js(v)));
515                        continue;
516                    }
517                }
518                parts.push(json_to_js(v));
519            }
520        }
521    }
522
523    (setup_lines, parts.join(", "))
524}
525
526fn render_assertion(out: &mut String, assertion: &Assertion, result_var: &str, field_resolver: &FieldResolver) {
527    // Skip assertions on fields that don't exist on the result type.
528    if let Some(f) = &assertion.field {
529        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
530            let _ = writeln!(out, "    // skipped: field '{f}' not available on result type");
531            return;
532        }
533    }
534
535    let field_expr = match &assertion.field {
536        Some(f) if !f.is_empty() => field_resolver.accessor(f, "typescript", result_var),
537        _ => result_var.to_string(),
538    };
539
540    match assertion.assertion_type.as_str() {
541        "equals" => {
542            if let Some(expected) = &assertion.value {
543                let js_val = json_to_js(expected);
544                // For string equality, trim trailing whitespace to handle trailing newlines
545                // from the converter. Use null-coalescing for optional fields.
546                if expected.is_string() {
547                    let resolved = assertion.field.as_deref().unwrap_or("");
548                    if !resolved.is_empty() && field_resolver.is_optional(field_resolver.resolve(resolved)) {
549                        let _ = writeln!(out, "    expect(({field_expr} ?? \"\").trim()).toBe({js_val});");
550                    } else {
551                        let _ = writeln!(out, "    expect({field_expr}.trim()).toBe({js_val});");
552                    }
553                } else {
554                    let _ = writeln!(out, "    expect({field_expr}).toBe({js_val});");
555                }
556            }
557        }
558        "contains" => {
559            if let Some(expected) = &assertion.value {
560                let js_val = json_to_js(expected);
561                // Use null-coalescing for optional string fields to handle null/undefined values.
562                let resolved = assertion.field.as_deref().unwrap_or("");
563                if !resolved.is_empty()
564                    && expected.is_string()
565                    && field_resolver.is_optional(field_resolver.resolve(resolved))
566                {
567                    let _ = writeln!(out, "    expect({field_expr} ?? \"\").toContain({js_val});");
568                } else {
569                    let _ = writeln!(out, "    expect({field_expr}).toContain({js_val});");
570                }
571            }
572        }
573        "contains_all" => {
574            if let Some(values) = &assertion.values {
575                for val in values {
576                    let js_val = json_to_js(val);
577                    let _ = writeln!(out, "    expect({field_expr}).toContain({js_val});");
578                }
579            }
580        }
581        "not_contains" => {
582            if let Some(expected) = &assertion.value {
583                let js_val = json_to_js(expected);
584                let _ = writeln!(out, "    expect({field_expr}).not.toContain({js_val});");
585            }
586        }
587        "not_empty" => {
588            // Use null-coalescing for optional fields to handle null/undefined values.
589            let resolved = assertion.field.as_deref().unwrap_or("");
590            if !resolved.is_empty() && field_resolver.is_optional(field_resolver.resolve(resolved)) {
591                let _ = writeln!(out, "    expect(({field_expr} ?? \"\").length).toBeGreaterThan(0);");
592            } else {
593                let _ = writeln!(out, "    expect({field_expr}.length).toBeGreaterThan(0);");
594            }
595        }
596        "is_empty" => {
597            // Use null-coalescing for optional string fields to handle null/undefined values.
598            let resolved = assertion.field.as_deref().unwrap_or("");
599            if !resolved.is_empty() && field_resolver.is_optional(field_resolver.resolve(resolved)) {
600                let _ = writeln!(out, "    expect({field_expr} ?? \"\").toHaveLength(0);");
601            } else {
602                let _ = writeln!(out, "    expect({field_expr}).toHaveLength(0);");
603            }
604        }
605        "contains_any" => {
606            if let Some(values) = &assertion.values {
607                let items: Vec<String> = values.iter().map(json_to_js).collect();
608                let arr_str = items.join(", ");
609                let _ = writeln!(
610                    out,
611                    "    expect([{arr_str}].some((v) => {field_expr}.includes(v))).toBe(true);"
612                );
613            }
614        }
615        "greater_than" => {
616            if let Some(val) = &assertion.value {
617                let js_val = json_to_js(val);
618                let _ = writeln!(out, "    expect({field_expr}).toBeGreaterThan({js_val});");
619            }
620        }
621        "less_than" => {
622            if let Some(val) = &assertion.value {
623                let js_val = json_to_js(val);
624                let _ = writeln!(out, "    expect({field_expr}).toBeLessThan({js_val});");
625            }
626        }
627        "greater_than_or_equal" => {
628            if let Some(val) = &assertion.value {
629                let js_val = json_to_js(val);
630                let _ = writeln!(out, "    expect({field_expr}).toBeGreaterThanOrEqual({js_val});");
631            }
632        }
633        "less_than_or_equal" => {
634            if let Some(val) = &assertion.value {
635                let js_val = json_to_js(val);
636                let _ = writeln!(out, "    expect({field_expr}).toBeLessThanOrEqual({js_val});");
637            }
638        }
639        "starts_with" => {
640            if let Some(expected) = &assertion.value {
641                let js_val = json_to_js(expected);
642                // Use null-coalescing for optional fields to handle null/undefined values.
643                let resolved = assertion.field.as_deref().unwrap_or("");
644                if !resolved.is_empty() && field_resolver.is_optional(field_resolver.resolve(resolved)) {
645                    let _ = writeln!(
646                        out,
647                        "    expect(({field_expr} ?? \"\").startsWith({js_val})).toBe(true);"
648                    );
649                } else {
650                    let _ = writeln!(out, "    expect({field_expr}.startsWith({js_val})).toBe(true);");
651                }
652            }
653        }
654        "count_min" => {
655            if let Some(val) = &assertion.value {
656                if let Some(n) = val.as_u64() {
657                    let _ = writeln!(out, "    expect({field_expr}.length).toBeGreaterThanOrEqual({n});");
658                }
659            }
660        }
661        "count_equals" => {
662            if let Some(val) = &assertion.value {
663                if let Some(n) = val.as_u64() {
664                    let _ = writeln!(out, "    expect({field_expr}.length).toBe({n});");
665                }
666            }
667        }
668        "is_true" => {
669            let _ = writeln!(out, "    expect({field_expr}).toBe(true);");
670        }
671        "not_error" => {
672            // No-op — if we got here, the call succeeded (it would have thrown).
673        }
674        "error" => {
675            // Handled at the test level (early return above).
676        }
677        other => {
678            let _ = writeln!(out, "    // TODO: unsupported assertion type: {other}");
679        }
680    }
681}
682
683/// Convert a `serde_json::Value` to a JavaScript literal string with camelCase object keys.
684///
685/// NAPI-RS bindings use camelCase for JavaScript field names. This variant converts
686/// snake_case object keys (as written in fixture JSON) to camelCase so that the
687/// generated config objects match the NAPI binding's expected field names.
688fn json_to_js_camel(value: &serde_json::Value) -> String {
689    match value {
690        serde_json::Value::Object(map) => {
691            let entries: Vec<String> = map
692                .iter()
693                .map(|(k, v)| {
694                    let camel_key = snake_to_camel(k);
695                    // Quote keys that aren't valid JS identifiers.
696                    let key = if camel_key
697                        .chars()
698                        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
699                        && !camel_key.starts_with(|c: char| c.is_ascii_digit())
700                    {
701                        camel_key.clone()
702                    } else {
703                        format!("\"{}\"", escape_js(&camel_key))
704                    };
705                    format!("{key}: {}", json_to_js_camel(v))
706                })
707                .collect();
708            format!("{{ {} }}", entries.join(", "))
709        }
710        serde_json::Value::Array(arr) => {
711            let items: Vec<String> = arr.iter().map(json_to_js_camel).collect();
712            format!("[{}]", items.join(", "))
713        }
714        // Scalars and null delegate to the standard converter.
715        other => json_to_js(other),
716    }
717}
718
719/// Convert a snake_case string to camelCase.
720fn snake_to_camel(s: &str) -> String {
721    let mut result = String::with_capacity(s.len());
722    let mut capitalize_next = false;
723    for ch in s.chars() {
724        if ch == '_' {
725            capitalize_next = true;
726        } else if capitalize_next {
727            result.extend(ch.to_uppercase());
728            capitalize_next = false;
729        } else {
730            result.push(ch);
731        }
732    }
733    result
734}
735
736/// Convert a `serde_json::Value` to a JavaScript literal string.
737fn json_to_js(value: &serde_json::Value) -> String {
738    match value {
739        serde_json::Value::String(s) => format!("\"{}\"", escape_js(s)),
740        serde_json::Value::Bool(b) => b.to_string(),
741        serde_json::Value::Number(n) => n.to_string(),
742        serde_json::Value::Null => "null".to_string(),
743        serde_json::Value::Array(arr) => {
744            let items: Vec<String> = arr.iter().map(json_to_js).collect();
745            format!("[{}]", items.join(", "))
746        }
747        serde_json::Value::Object(map) => {
748            let entries: Vec<String> = map
749                .iter()
750                .map(|(k, v)| {
751                    // Quote keys that aren't valid JS identifiers (contain hyphens, spaces, etc.)
752                    let key = if k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
753                        && !k.starts_with(|c: char| c.is_ascii_digit())
754                    {
755                        k.clone()
756                    } else {
757                        format!("\"{}\"", escape_js(k))
758                    };
759                    format!("{key}: {}", json_to_js(v))
760                })
761                .collect();
762            format!("{{ {} }}", entries.join(", "))
763        }
764    }
765}
766
767// ---------------------------------------------------------------------------
768// Visitor generation
769// ---------------------------------------------------------------------------
770
771/// Build a TypeScript visitor object and add setup line. Returns the visitor variable name.
772fn build_typescript_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
773    use std::fmt::Write as FmtWrite;
774    let mut visitor_obj = String::new();
775    let _ = writeln!(visitor_obj, "{{");
776    for (method_name, action) in &visitor_spec.callbacks {
777        emit_typescript_visitor_method(&mut visitor_obj, method_name, action);
778    }
779    let _ = writeln!(visitor_obj, "    }}");
780
781    setup_lines.push(format!("const _testVisitor = {visitor_obj}"));
782    "_testVisitor".to_string()
783}
784
785/// Emit a TypeScript visitor method for a callback action.
786fn emit_typescript_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
787    use std::fmt::Write as FmtWrite;
788
789    let camel_method = to_camel_case(method_name);
790    let params = match method_name {
791        "visit_link" => "ctx, href, text, title",
792        "visit_image" => "ctx, src, alt, title",
793        "visit_heading" => "ctx, level, text, id",
794        "visit_code_block" => "ctx, lang, code",
795        "visit_code_inline"
796        | "visit_strong"
797        | "visit_emphasis"
798        | "visit_strikethrough"
799        | "visit_underline"
800        | "visit_subscript"
801        | "visit_superscript"
802        | "visit_mark"
803        | "visit_button"
804        | "visit_summary"
805        | "visit_figcaption"
806        | "visit_definition_term"
807        | "visit_definition_description" => "ctx, text",
808        "visit_text" => "ctx, text",
809        "visit_list_item" => "ctx, ordered, marker, text",
810        "visit_blockquote" => "ctx, content, depth",
811        "visit_table_row" => "ctx, cells, isHeader",
812        "visit_custom_element" => "ctx, tagName, html",
813        "visit_form" => "ctx, actionUrl, method",
814        "visit_input" => "ctx, inputType, name, value",
815        "visit_audio" | "visit_video" | "visit_iframe" => "ctx, src",
816        "visit_details" => "ctx, isOpen",
817        _ => "ctx",
818    };
819
820    let _ = writeln!(
821        out,
822        "    {camel_method}({params}): string | {{{{ custom: string }}}} {{"
823    );
824    match action {
825        CallbackAction::Skip => {
826            let _ = writeln!(out, "        return \"skip\";");
827        }
828        CallbackAction::Continue => {
829            let _ = writeln!(out, "        return \"continue\";");
830        }
831        CallbackAction::PreserveHtml => {
832            let _ = writeln!(out, "        return \"preserve_html\";");
833        }
834        CallbackAction::Custom { output } => {
835            let escaped = escape_js(output);
836            let _ = writeln!(out, "        return {{ custom: {escaped} }};");
837        }
838        CallbackAction::CustomTemplate { template } => {
839            let _ = writeln!(out, "        return {{ custom: `{template}` }};");
840        }
841    }
842    let _ = writeln!(out, "    }},");
843}
844
845/// Convert snake_case to camelCase for method names.
846fn to_camel_case(snake: &str) -> String {
847    use heck::ToLowerCamelCase;
848    snake.to_lower_camel_case()
849}