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, expand_fixture_templates, 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 alef_core::hash::{self, CommentStyle};
10use alef_core::template_versions as tv;
11use anyhow::Result;
12use heck::ToUpperCamelCase;
13use std::fmt::Write as FmtWrite;
14use std::path::PathBuf;
15
16use super::E2eCodegen;
17
18/// TypeScript e2e code generator.
19pub struct TypeScriptCodegen;
20
21impl E2eCodegen for TypeScriptCodegen {
22    fn generate(
23        &self,
24        groups: &[FixtureGroup],
25        e2e_config: &E2eConfig,
26        _alef_config: &AlefConfig,
27    ) -> Result<Vec<GeneratedFile>> {
28        let output_base = PathBuf::from(e2e_config.effective_output()).join(self.language_name());
29        let tests_base = output_base.join("tests");
30
31        let mut files = Vec::new();
32
33        // Resolve call config with overrides — use "node" key (Language::Node).
34        let call = &e2e_config.call;
35        let overrides = call.overrides.get("node");
36        let module_path = overrides
37            .and_then(|o| o.module.as_ref())
38            .cloned()
39            .unwrap_or_else(|| call.module.clone());
40        let function_name = overrides
41            .and_then(|o| o.function.as_ref())
42            .cloned()
43            .unwrap_or_else(|| call.function.clone());
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        // Determine whether any group has HTTP server test fixtures.
65        let has_http_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| f.is_http_test());
66
67        // Generate package.json.
68        files.push(GeneratedFile {
69            path: output_base.join("package.json"),
70            content: render_package_json(
71                &pkg_name,
72                &pkg_path,
73                &pkg_version,
74                e2e_config.dep_mode,
75                has_http_fixtures,
76            ),
77            generated_header: false,
78        });
79
80        // Generate tsconfig.json.
81        files.push(GeneratedFile {
82            path: output_base.join("tsconfig.json"),
83            content: render_tsconfig(),
84            generated_header: false,
85        });
86
87        // Check if we need global setup (either for client_factory or HTTP tests).
88        let needs_global_setup = client_factory.is_some() || has_http_fixtures;
89
90        // Generate vitest.config.ts — include globalSetup when needed.
91        files.push(GeneratedFile {
92            path: output_base.join("vitest.config.ts"),
93            content: render_vitest_config(needs_global_setup),
94            generated_header: true,
95        });
96
97        // Generate globalSetup.ts when needed (for mock server or HTTP tests).
98        if needs_global_setup {
99            files.push(GeneratedFile {
100                path: output_base.join("globalSetup.ts"),
101                content: render_global_setup(),
102                generated_header: true,
103            });
104        }
105
106        // Resolve options_type from override.
107        let options_type = overrides.and_then(|o| o.options_type.clone());
108        let field_resolver = FieldResolver::new(
109            &e2e_config.fields,
110            &e2e_config.fields_optional,
111            &e2e_config.result_fields,
112            &e2e_config.fields_array,
113        );
114
115        // Generate test files per category.
116        for group in groups {
117            let active: Vec<&Fixture> = group
118                .fixtures
119                .iter()
120                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip("node")))
121                .collect();
122
123            if active.is_empty() {
124                continue;
125            }
126
127            let filename = format!("{}.test.ts", sanitize_filename(&group.category));
128            let content = render_test_file(
129                &group.category,
130                &active,
131                &module_path,
132                &pkg_name,
133                &function_name,
134                &e2e_config.call.args,
135                options_type.as_deref(),
136                &field_resolver,
137                client_factory,
138                e2e_config,
139            );
140            files.push(GeneratedFile {
141                path: tests_base.join(filename),
142                content,
143                generated_header: true,
144            });
145        }
146
147        Ok(files)
148    }
149
150    fn language_name(&self) -> &'static str {
151        "node"
152    }
153}
154
155fn render_package_json(
156    pkg_name: &str,
157    _pkg_path: &str,
158    pkg_version: &str,
159    dep_mode: crate::config::DependencyMode,
160    has_http_fixtures: bool,
161) -> String {
162    let dep_value = match dep_mode {
163        crate::config::DependencyMode::Registry => pkg_version.to_string(),
164        crate::config::DependencyMode::Local => "workspace:*".to_string(),
165    };
166    let _ = has_http_fixtures; // TODO: add HTTP test deps when http fixtures are present
167    format!(
168        r#"{{
169  "name": "{pkg_name}-e2e-typescript",
170  "version": "0.1.0",
171  "private": true,
172  "type": "module",
173  "scripts": {{
174    "test": "vitest run"
175  }},
176  "devDependencies": {{
177    "{pkg_name}": "{dep_value}",
178    "vitest": "{vitest}"
179  }}
180}}
181"#,
182        vitest = tv::npm::VITEST,
183    )
184}
185
186fn render_tsconfig() -> String {
187    r#"{
188  "compilerOptions": {
189    "target": "ES2022",
190    "module": "ESNext",
191    "moduleResolution": "bundler",
192    "strict": true,
193    "strictNullChecks": false,
194    "esModuleInterop": true,
195    "skipLibCheck": true
196  },
197  "include": ["tests/**/*.ts", "vitest.config.ts"]
198}
199"#
200    .to_string()
201}
202
203fn render_vitest_config(with_global_setup: bool) -> String {
204    let header = hash::header(CommentStyle::DoubleSlash);
205    if with_global_setup {
206        format!(
207            r#"{header}import {{ defineConfig }} from 'vitest/config';
208
209export default defineConfig({{
210  test: {{
211    include: ['tests/**/*.test.ts'],
212    globalSetup: './globalSetup.ts',
213  }},
214}});
215"#
216        )
217    } else {
218        format!(
219            r#"{header}import {{ defineConfig }} from 'vitest/config';
220
221export default defineConfig({{
222  test: {{
223    include: ['tests/**/*.test.ts'],
224  }},
225}});
226"#
227        )
228    }
229}
230
231fn render_global_setup() -> String {
232    let header = hash::header(CommentStyle::DoubleSlash);
233    header
234        + r#"import { spawn } from 'child_process';
235import { resolve } from 'path';
236
237let serverProcess: any;
238
239// HTTP client wrapper for making requests to mock server
240const createApp = (baseUrl: string) => ({
241  async request(path: string, init?: RequestInit): Promise<Response> {
242    const url = new URL(path, baseUrl);
243    return fetch(url.toString(), init);
244  },
245});
246
247export async function setup() {
248  // Mock server binary must be pre-built (e.g. by CI or `cargo build --manifest-path e2e/rust/Cargo.toml --bin mock-server --release`)
249  serverProcess = spawn(
250    resolve(__dirname, '../rust/target/release/mock-server'),
251    [resolve(__dirname, '../../fixtures')],
252    { stdio: ['pipe', 'pipe', 'inherit'] }
253  );
254
255  const url = await new Promise<string>((resolve, reject) => {
256    serverProcess.stdout.on('data', (data: any) => {
257      const match = data.toString().match(/MOCK_SERVER_URL=(.*)/);
258      if (match) resolve(match[1].trim());
259    });
260    setTimeout(() => reject(new Error('Mock server startup timeout')), 30000);
261  });
262
263  process.env.MOCK_SERVER_URL = url;
264
265  // Make app available globally to all tests
266  (globalThis as any).app = createApp(url);
267}
268
269export async function teardown() {
270  if (serverProcess) {
271    serverProcess.stdin.end();
272    serverProcess.kill();
273  }
274}
275"#
276}
277
278#[allow(clippy::too_many_arguments)]
279fn render_test_file(
280    category: &str,
281    fixtures: &[&Fixture],
282    module_path: &str,
283    pkg_name: &str,
284    function_name: &str,
285    args: &[crate::config::ArgMapping],
286    options_type: Option<&str>,
287    field_resolver: &FieldResolver,
288    client_factory: Option<&str>,
289    e2e_config: &E2eConfig,
290) -> String {
291    let mut out = String::new();
292    out.push_str(&hash::header(CommentStyle::DoubleSlash));
293    let _ = writeln!(out, "import {{ describe, expect, it }} from 'vitest';");
294
295    let has_http_fixtures = fixtures.iter().any(|f| f.is_http_test());
296    // Only treat as "has non-HTTP fixtures" when at least one non-HTTP fixture has assertions
297    // (fixtures with no assertions are emitted as it.skip stubs that don't call any imports).
298    let has_non_http_fixtures = fixtures.iter().any(|f| !f.is_http_test() && !f.assertions.is_empty());
299
300    // Check if any fixture uses a json_object arg that needs the options type import.
301    let needs_options_import = options_type.is_some()
302        && fixtures.iter().any(|f| {
303            args.iter().any(|arg| {
304                let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
305                let val = if field == "input" {
306                    Some(&f.input)
307                } else {
308                    f.input.get(field)
309                };
310                arg.arg_type == "json_object" && val.is_some_and(|v| !v.is_null())
311            })
312        });
313
314    // Collect handle constructor function names that need to be imported.
315    let handle_constructors: Vec<String> = args
316        .iter()
317        .filter(|arg| arg.arg_type == "handle")
318        .map(|arg| format!("create{}", arg.name.to_upper_camel_case()))
319        .collect();
320
321    // Build imports for non-HTTP fixtures.
322    if has_non_http_fixtures {
323        // When using client_factory, import the factory instead of the function.
324        let mut imports: Vec<String> = if let Some(factory) = client_factory {
325            vec![factory.to_string()]
326        } else {
327            vec![function_name.to_string()]
328        };
329
330        // Also import any additional function names used by per-fixture call overrides.
331        for fixture in fixtures.iter().filter(|f| !f.is_http_test()) {
332            if fixture.call.is_some() {
333                let call_config = e2e_config.resolve_call(fixture.call.as_deref());
334                let fixture_fn = resolve_node_function_name(call_config);
335                if client_factory.is_none() && !imports.contains(&fixture_fn) {
336                    imports.push(fixture_fn);
337                }
338            }
339        }
340
341        // Collect tree helper function names needed by method_result assertions.
342        for fixture in fixtures.iter().filter(|f| !f.is_http_test()) {
343            for assertion in &fixture.assertions {
344                if assertion.assertion_type == "method_result" {
345                    if let Some(method_name) = &assertion.method {
346                        let helper = ts_method_helper_import(method_name);
347                        if let Some(helper_fn) = helper {
348                            if !imports.contains(&helper_fn) {
349                                imports.push(helper_fn);
350                            }
351                        }
352                    }
353                }
354            }
355        }
356
357        for ctor in &handle_constructors {
358            if !imports.contains(ctor) {
359                imports.push(ctor.clone());
360            }
361        }
362
363        // Use pkg_name (the npm package name, e.g. "@kreuzberg/liter-llm") for
364        // the import specifier so that registry builds resolve the published package name.
365        let _ = module_path; // retained in signature for potential future use
366        if let (true, Some(opts_type)) = (needs_options_import, options_type) {
367            imports.push(format!("type {opts_type}"));
368            let imports_str = imports.join(", ");
369            let _ = writeln!(out, "import {{ {imports_str} }} from '{pkg_name}';");
370        } else {
371            let imports_str = imports.join(", ");
372            let _ = writeln!(out, "import {{ {imports_str} }} from '{pkg_name}';");
373        }
374    }
375
376    let _ = writeln!(out);
377    let _ = writeln!(out, "describe('{category}', () => {{");
378
379    for (i, fixture) in fixtures.iter().enumerate() {
380        if fixture.is_http_test() {
381            render_http_test_case(&mut out, fixture);
382        } else {
383            render_test_case(
384                &mut out,
385                fixture,
386                client_factory,
387                options_type,
388                field_resolver,
389                e2e_config,
390            );
391        }
392        if i + 1 < fixtures.len() {
393            let _ = writeln!(out);
394        }
395    }
396
397    // Suppress unused variable warning when file has only HTTP fixtures.
398    let _ = has_http_fixtures;
399
400    let _ = writeln!(out, "}});");
401    out
402}
403
404/// Resolve the function name for a call config, applying node-specific overrides.
405fn resolve_node_function_name(call_config: &crate::config::CallConfig) -> String {
406    call_config
407        .overrides
408        .get("node")
409        .and_then(|o| o.function.clone())
410        .unwrap_or_else(|| call_config.function.clone())
411}
412
413/// Return the package-level helper function name to import for a method_result method,
414/// or `None` if the method maps to a property access (no import needed).
415fn ts_method_helper_import(method_name: &str) -> Option<String> {
416    match method_name {
417        "has_error_nodes" => Some("treeHasErrorNodes".to_string()),
418        "error_count" | "tree_error_count" => Some("treeErrorCount".to_string()),
419        "tree_to_sexp" => Some("treeToSexp".to_string()),
420        "contains_node_type" => Some("treeContainsNodeType".to_string()),
421        "find_nodes_by_type" => Some("findNodesByType".to_string()),
422        "run_query" => Some("runQuery".to_string()),
423        // Property accesses (root_child_count, root_node_type, named_children_count)
424        // and unknown methods that become `result.method()` don't need extra imports.
425        _ => None,
426    }
427}
428
429// ---------------------------------------------------------------------------
430// HTTP server test rendering
431// ---------------------------------------------------------------------------
432
433/// Render a vitest `it` block for an HTTP server fixture.
434///
435/// The generated test uses the Hono/Fastify app's `.request()` method (or equivalent
436/// test helper) available as `app` from a module-level `beforeAll` setup. For now
437/// the test body stubs the `app` reference — callers that integrate this into a real
438/// framework supply the appropriate setup.
439fn render_http_test_case(out: &mut String, fixture: &Fixture) {
440    let Some(http) = &fixture.http else {
441        return;
442    };
443
444    let test_name = sanitize_ident(&fixture.id);
445    let description = fixture.description.replace('\'', "\\'");
446
447    // HTTP 101 (WebSocket upgrade) — fetch cannot handle upgrade responses.
448    if http.expected_response.status_code == 101 {
449        let _ = writeln!(out, "  it.skip('{test_name}: {description}', async () => {{");
450        let _ = writeln!(out, "    // HTTP 101 WebSocket upgrade cannot be tested via fetch");
451        let _ = writeln!(out, "  }});");
452        return;
453    }
454
455    let method = http.request.method.to_uppercase();
456
457    // Build the init object for `fetch(url, init)`.
458    let mut init_entries: Vec<String> = Vec::new();
459    init_entries.push(format!("method: '{method}'"));
460    // Do not follow redirects — tests that assert on 3xx status codes need the original response.
461    init_entries.push("redirect: 'manual'".to_string());
462
463    // Headers
464    if !http.request.headers.is_empty() {
465        let entries: Vec<String> = http
466            .request
467            .headers
468            .iter()
469            .map(|(k, v)| {
470                let expanded_v = expand_fixture_templates(v);
471                format!("      \"{}\": \"{}\"", escape_js(k), escape_js(&expanded_v))
472            })
473            .collect();
474        init_entries.push(format!("headers: {{\n{},\n    }}", entries.join(",\n")));
475    }
476
477    // Body
478    if let Some(body) = &http.request.body {
479        let js_body = json_to_js(body);
480        init_entries.push(format!("body: JSON.stringify({js_body})"));
481    }
482
483    let fixture_id = escape_js(&fixture.id);
484    let _ = writeln!(out, "  it('{test_name}: {description}', async () => {{");
485    let _ = writeln!(
486        out,
487        "    const mockUrl = `${{process.env.MOCK_SERVER_URL}}/fixtures/{fixture_id}`;"
488    );
489
490    let init_str = init_entries.join(", ");
491    let _ = writeln!(out, "    const response = await fetch(mockUrl, {{ {init_str} }});");
492
493    // Status code assertion.
494    let status = http.expected_response.status_code;
495    let _ = writeln!(out, "    expect(response.status).toBe({status});");
496
497    // Body assertions.
498    if let Some(expected_body) = &http.expected_response.body {
499        // Empty-string sentinel ("") and null mean no body — skip assertion.
500        if !(expected_body.is_null() || expected_body.is_string() && expected_body.as_str() == Some("")) {
501            if let serde_json::Value::String(s) = expected_body {
502                // Plain-string body: mock server returns raw text, compare as text.
503                let escaped = escape_js(s);
504                let _ = writeln!(out, "    const text = await response.text();");
505                let _ = writeln!(out, "    expect(text).toBe('{escaped}');");
506            } else {
507                let js_val = json_to_js(expected_body);
508                let _ = writeln!(out, "    const data = await response.json();");
509                let _ = writeln!(out, "    expect(data).toEqual({js_val});");
510            }
511        }
512    } else if let Some(partial) = &http.expected_response.body_partial {
513        let _ = writeln!(out, "    const data = await response.json();");
514        if let Some(obj) = partial.as_object() {
515            for (key, val) in obj {
516                let js_key = escape_js(key);
517                let js_val = json_to_js(val);
518                let _ = writeln!(
519                    out,
520                    "    expect((data as Record<string, unknown>)['{js_key}']).toEqual({js_val});"
521                );
522            }
523        }
524    }
525
526    // Header assertions.
527    for (header_name, header_value) in &http.expected_response.headers {
528        let lower_name = header_name.to_lowercase();
529        // The mock server strips content-encoding headers because it returns uncompressed bodies.
530        if lower_name == "content-encoding" {
531            continue;
532        }
533        let escaped_name = escape_js(&lower_name);
534        match header_value.as_str() {
535            "<<present>>" => {
536                let _ = writeln!(
537                    out,
538                    "    expect(response.headers.get('{escaped_name}')).not.toBeNull();"
539                );
540            }
541            "<<absent>>" => {
542                let _ = writeln!(out, "    expect(response.headers.get('{escaped_name}')).toBeNull();");
543            }
544            "<<uuid>>" => {
545                let _ = writeln!(
546                    out,
547                    "    expect(response.headers.get('{escaped_name}')).toMatch(/^[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}$/);"
548                );
549            }
550            exact => {
551                let escaped_val = escape_js(exact);
552                let _ = writeln!(
553                    out,
554                    "    expect(response.headers.get('{escaped_name}')).toBe('{escaped_val}');"
555                );
556            }
557        }
558    }
559
560    // Validation error assertions — skip when a full body assertion is already generated
561    // (redundant, and response.json() can only be called once per response).
562    let body_has_content = matches!(&http.expected_response.body, Some(v)
563        if !(v.is_null() || (v.is_string() && v.as_str() == Some(""))));
564    if let Some(validation_errors) = &http.expected_response.validation_errors {
565        if !validation_errors.is_empty() && !body_has_content {
566            let _ = writeln!(
567                out,
568                "    const body = await response.json() as {{ errors?: unknown[] }};"
569            );
570            let _ = writeln!(out, "    const errors = body.errors ?? [];");
571            for ve in validation_errors {
572                let loc_js: Vec<String> = ve.loc.iter().map(|s| format!("\"{}\"", escape_js(s))).collect();
573                let loc_str = loc_js.join(", ");
574                let expanded_msg = expand_fixture_templates(&ve.msg);
575                let escaped_msg = escape_js(&expanded_msg);
576                let _ = writeln!(
577                    out,
578                    "    expect((errors as Array<Record<string, unknown>>).some((e) => JSON.stringify(e[\"loc\"]) === JSON.stringify([{loc_str}]) && String(e[\"msg\"]).includes(\"{escaped_msg}\"))).toBe(true);"
579                );
580            }
581        }
582    }
583
584    let _ = writeln!(out, "  }});");
585}
586
587// ---------------------------------------------------------------------------
588// Function-call test rendering
589// ---------------------------------------------------------------------------
590
591#[allow(clippy::too_many_arguments)]
592fn render_test_case(
593    out: &mut String,
594    fixture: &Fixture,
595    client_factory: Option<&str>,
596    options_type: Option<&str>,
597    field_resolver: &FieldResolver,
598    e2e_config: &E2eConfig,
599) {
600    // Resolve per-fixture call config (supports `"call": "parse"` overrides in fixtures).
601    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
602    let function_name = resolve_node_function_name(call_config);
603    let result_var = &call_config.result_var;
604    let is_async = call_config.r#async;
605    let args = &call_config.args;
606
607    let test_name = sanitize_ident(&fixture.id);
608    let description = fixture.description.replace('\'', "\\'");
609    let async_kw = if is_async { "async " } else { "" };
610    let await_kw = if is_async { "await " } else { "" };
611
612    // Build the call expression — either `client.method(args)` or `method(args)`
613    let (mut setup_lines, args_str) = build_args_and_setup(&fixture.input, args, options_type, &fixture.id);
614
615    // Build visitor if present and add to setup
616    let mut visitor_arg = String::new();
617    if let Some(visitor_spec) = &fixture.visitor {
618        visitor_arg = build_typescript_visitor(&mut setup_lines, visitor_spec);
619    }
620
621    let final_args = if visitor_arg.is_empty() {
622        args_str
623    } else if args_str.is_empty() {
624        format!("{{ visitor: {visitor_arg} }}")
625    } else {
626        format!("{args_str}, {{ visitor: {visitor_arg} }}")
627    };
628
629    let call_expr = if client_factory.is_some() {
630        format!("client.{function_name}({final_args})")
631    } else {
632        format!("{function_name}({final_args})")
633    };
634
635    // Build the base_url expression for mock server
636    let base_url_expr = format!("`${{process.env.MOCK_SERVER_URL}}/fixtures/{}`", fixture.id);
637
638    // Check if this is an error-expecting test.
639    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
640
641    // Skip tests with no assertions — they would call a stub function that may not exist.
642    if fixture.assertions.is_empty() {
643        let _ = writeln!(out, "  it.skip('{test_name}: {description}', async () => {{");
644        let _ = writeln!(out, "    // no assertions configured for this fixture in node e2e");
645        let _ = writeln!(out, "  }});");
646        return;
647    }
648
649    if expects_error {
650        let _ = writeln!(out, "  it('{test_name}: {description}', async () => {{");
651        if let Some(factory) = client_factory {
652            let _ = writeln!(out, "    const client = {factory}('test-key', {base_url_expr});");
653        }
654        // Wrap ALL setup lines and the function call inside the expect block so that
655        // synchronous throws from handle constructors (e.g. createEngine) are also caught.
656        let _ = writeln!(out, "    await expect(async () => {{");
657        for line in &setup_lines {
658            let _ = writeln!(out, "      {line}");
659        }
660        let _ = writeln!(out, "      await {call_expr};");
661        let _ = writeln!(out, "    }}).rejects.toThrow();");
662        let _ = writeln!(out, "  }});");
663        return;
664    }
665
666    let _ = writeln!(out, "  it('{test_name}: {description}', {async_kw}() => {{");
667
668    if let Some(factory) = client_factory {
669        let _ = writeln!(out, "    const client = {factory}('test-key', {base_url_expr});");
670    }
671
672    for line in &setup_lines {
673        let _ = writeln!(out, "    {line}");
674    }
675
676    // Check if any assertion actually uses the result variable.
677    let has_usable_assertion = fixture.assertions.iter().any(|a| {
678        if a.assertion_type == "not_error" || a.assertion_type == "error" {
679            return false;
680        }
681        match &a.field {
682            Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
683            _ => true,
684        }
685    });
686
687    if has_usable_assertion {
688        let _ = writeln!(out, "    const {result_var} = {await_kw}{call_expr};");
689    } else {
690        let _ = writeln!(out, "    {await_kw}{call_expr};");
691    }
692
693    // Emit assertions.
694    for assertion in &fixture.assertions {
695        // A2: skip not_error assertions when returns_result=false (non-Result calls don't return errors).
696        if assertion.assertion_type == "not_error" && !call_config.returns_result {
697            continue;
698        }
699        render_assertion(out, assertion, result_var, field_resolver);
700    }
701
702    let _ = writeln!(out, "  }});");
703}
704
705/// Build setup lines (e.g. handle creation) and the argument list for the function call.
706///
707/// Returns `(setup_lines, args_string)`.
708fn build_args_and_setup(
709    input: &serde_json::Value,
710    args: &[crate::config::ArgMapping],
711    options_type: Option<&str>,
712    fixture_id: &str,
713) -> (Vec<String>, String) {
714    if args.is_empty() {
715        // If no args mapping, pass the whole input as a single argument.
716        return (Vec::new(), json_to_js(input));
717    }
718
719    let mut setup_lines: Vec<String> = Vec::new();
720    let mut parts: Vec<String> = Vec::new();
721
722    for arg in args {
723        if arg.arg_type == "mock_url" {
724            setup_lines.push(format!(
725                "const {} = `${{process.env.MOCK_SERVER_URL}}/fixtures/{fixture_id}`;",
726                arg.name,
727            ));
728            parts.push(arg.name.clone());
729            continue;
730        }
731
732        if arg.arg_type == "handle" {
733            // Generate a createEngine (or equivalent) call and pass the variable.
734            let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
735            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
736            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
737            if config_value.is_null()
738                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
739            {
740                setup_lines.push(format!("const {} = {constructor_name}(null);", arg.name));
741            } else {
742                // NAPI-RS bindings use camelCase for JS field names, so convert snake_case
743                // config keys from the fixture JSON to camelCase before passing to the constructor.
744                let literal = json_to_js_camel(config_value);
745                setup_lines.push(format!("const {name}Config = {literal};", name = arg.name,));
746                setup_lines.push(format!(
747                    "const {} = {constructor_name}({name}Config);",
748                    arg.name,
749                    name = arg.name,
750                ));
751            }
752            parts.push(arg.name.clone());
753            continue;
754        }
755
756        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
757        // When field == "input", the entire input object IS the value (not a nested key)
758        let val = if field == "input" {
759            Some(input)
760        } else {
761            input.get(field)
762        };
763        match val {
764            None | Some(serde_json::Value::Null) if arg.optional => {
765                // Optional arg with no fixture value: skip entirely.
766                continue;
767            }
768            None | Some(serde_json::Value::Null) => {
769                // Required arg with no fixture value: pass a language-appropriate default.
770                let default_val = match arg.arg_type.as_str() {
771                    "string" => "\"\"".to_string(),
772                    "int" | "integer" => "0".to_string(),
773                    "float" | "number" => "0.0".to_string(),
774                    "bool" | "boolean" => "false".to_string(),
775                    _ => "null".to_string(),
776                };
777                parts.push(default_val);
778            }
779            Some(v) => {
780                // For json_object args with options_type, cast the object literal.
781                if arg.arg_type == "json_object" {
782                    if let Some(opts_type) = options_type {
783                        parts.push(format!("{} as {opts_type}", json_to_js(v)));
784                        continue;
785                    }
786                }
787                parts.push(json_to_js(v));
788            }
789        }
790    }
791
792    (setup_lines, parts.join(", "))
793}
794
795fn render_assertion(out: &mut String, assertion: &Assertion, result_var: &str, field_resolver: &FieldResolver) {
796    // Handle synthetic / derived fields before the is_valid_for_result check
797    // so they are never treated as struct property accesses on the result.
798    if let Some(f) = &assertion.field {
799        match f.as_str() {
800            "chunks_have_content" => {
801                let pred = format!("({result_var}.chunks ?? []).every((c: {{ content?: string }}) => !!c.content)");
802                match assertion.assertion_type.as_str() {
803                    "is_true" => {
804                        let _ = writeln!(out, "    expect({pred}).toBe(true);");
805                    }
806                    "is_false" => {
807                        let _ = writeln!(out, "    expect({pred}).toBe(false);");
808                    }
809                    _ => {
810                        let _ = writeln!(
811                            out,
812                            "    // skipped: unsupported assertion type on synthetic field '{f}'"
813                        );
814                    }
815                }
816                return;
817            }
818            "chunks_have_embeddings" => {
819                let pred = format!(
820                    "({result_var}.chunks ?? []).every((c: {{ embedding?: number[] }}) => c.embedding != null && c.embedding.length > 0)"
821                );
822                match assertion.assertion_type.as_str() {
823                    "is_true" => {
824                        let _ = writeln!(out, "    expect({pred}).toBe(true);");
825                    }
826                    "is_false" => {
827                        let _ = writeln!(out, "    expect({pred}).toBe(false);");
828                    }
829                    _ => {
830                        let _ = writeln!(
831                            out,
832                            "    // skipped: unsupported assertion type on synthetic field '{f}'"
833                        );
834                    }
835                }
836                return;
837            }
838            // ---- EmbedResponse virtual fields ----
839            // embed_texts returns number[][] in TypeScript — no wrapper object.
840            // result_var is the embedding matrix; use it directly.
841            "embeddings" => {
842                match assertion.assertion_type.as_str() {
843                    "count_equals" => {
844                        if let Some(val) = &assertion.value {
845                            let js_val = json_to_js(val);
846                            let _ = writeln!(out, "    expect({result_var}.length).toBe({js_val});");
847                        }
848                    }
849                    "count_min" => {
850                        if let Some(val) = &assertion.value {
851                            let js_val = json_to_js(val);
852                            let _ = writeln!(out, "    expect({result_var}.length).toBeGreaterThanOrEqual({js_val});");
853                        }
854                    }
855                    "not_empty" => {
856                        let _ = writeln!(out, "    expect({result_var}.length).toBeGreaterThan(0);");
857                    }
858                    "is_empty" => {
859                        let _ = writeln!(out, "    expect({result_var}.length).toBe(0);");
860                    }
861                    _ => {
862                        let _ = writeln!(
863                            out,
864                            "    // skipped: unsupported assertion type on synthetic field 'embeddings'"
865                        );
866                    }
867                }
868                return;
869            }
870            "embedding_dimensions" => {
871                let expr = format!("({result_var}.length > 0 ? {result_var}[0].length : 0)");
872                match assertion.assertion_type.as_str() {
873                    "equals" => {
874                        if let Some(val) = &assertion.value {
875                            let js_val = json_to_js(val);
876                            let _ = writeln!(out, "    expect({expr}).toBe({js_val});");
877                        }
878                    }
879                    "greater_than" => {
880                        if let Some(val) = &assertion.value {
881                            let js_val = json_to_js(val);
882                            let _ = writeln!(out, "    expect({expr}).toBeGreaterThan({js_val});");
883                        }
884                    }
885                    _ => {
886                        let _ = writeln!(
887                            out,
888                            "    // skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
889                        );
890                    }
891                }
892                return;
893            }
894            "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
895                let pred = match f.as_str() {
896                    "embeddings_valid" => {
897                        format!("{result_var}.every((e: number[]) => e.length > 0)")
898                    }
899                    "embeddings_finite" => {
900                        format!("{result_var}.every((e: number[]) => e.every((v: number) => isFinite(v)))")
901                    }
902                    "embeddings_non_zero" => {
903                        format!("{result_var}.every((e: number[]) => e.some((v: number) => v !== 0))")
904                    }
905                    "embeddings_normalized" => {
906                        format!(
907                            "{result_var}.every((e: number[]) => {{ const n = e.reduce((s: number, v: number) => s + v * v, 0); return Math.abs(n - 1.0) < 1e-3; }})"
908                        )
909                    }
910                    _ => unreachable!(),
911                };
912                match assertion.assertion_type.as_str() {
913                    "is_true" => {
914                        let _ = writeln!(out, "    expect({pred}).toBe(true);");
915                    }
916                    "is_false" => {
917                        let _ = writeln!(out, "    expect({pred}).toBe(false);");
918                    }
919                    _ => {
920                        let _ = writeln!(
921                            out,
922                            "    // skipped: unsupported assertion type on synthetic field '{f}'"
923                        );
924                    }
925                }
926                return;
927            }
928            // ---- keywords / keywords_count ----
929            // Node JsExtractionResult does not expose extracted_keywords; skip.
930            "keywords" | "keywords_count" => {
931                let _ = writeln!(
932                    out,
933                    "    // skipped: field '{f}' not available on Node JsExtractionResult"
934                );
935                return;
936            }
937            _ => {}
938        }
939    }
940
941    // Skip assertions on fields that don't exist on the result type.
942    if let Some(f) = &assertion.field {
943        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
944            let _ = writeln!(out, "    // skipped: field '{f}' not available on result type");
945            return;
946        }
947    }
948
949    let field_expr = match &assertion.field {
950        Some(f) if !f.is_empty() => field_resolver.accessor(f, "typescript", result_var),
951        _ => result_var.to_string(),
952    };
953
954    match assertion.assertion_type.as_str() {
955        "equals" => {
956            if let Some(expected) = &assertion.value {
957                let js_val = json_to_js(expected);
958                // For string equality, trim trailing whitespace to handle trailing newlines
959                // from the converter. Use null-coalescing for optional fields.
960                if expected.is_string() {
961                    let resolved = assertion.field.as_deref().unwrap_or("");
962                    if !resolved.is_empty() && field_resolver.is_optional(field_resolver.resolve(resolved)) {
963                        let _ = writeln!(out, "    expect(({field_expr} ?? \"\").trim()).toBe({js_val});");
964                    } else {
965                        let _ = writeln!(out, "    expect({field_expr}.trim()).toBe({js_val});");
966                    }
967                } else {
968                    let _ = writeln!(out, "    expect({field_expr}).toBe({js_val});");
969                }
970            }
971        }
972        "contains" => {
973            if let Some(expected) = &assertion.value {
974                let js_val = json_to_js(expected);
975                // Use null-coalescing for optional string fields to handle null/undefined values.
976                let resolved = assertion.field.as_deref().unwrap_or("");
977                if !resolved.is_empty()
978                    && expected.is_string()
979                    && field_resolver.is_optional(field_resolver.resolve(resolved))
980                {
981                    let _ = writeln!(out, "    expect({field_expr} ?? \"\").toContain({js_val});");
982                } else {
983                    let _ = writeln!(out, "    expect({field_expr}).toContain({js_val});");
984                }
985            }
986        }
987        "contains_all" => {
988            if let Some(values) = &assertion.values {
989                for val in values {
990                    let js_val = json_to_js(val);
991                    let _ = writeln!(out, "    expect({field_expr}).toContain({js_val});");
992                }
993            }
994        }
995        "not_contains" => {
996            if let Some(expected) = &assertion.value {
997                let js_val = json_to_js(expected);
998                let _ = writeln!(out, "    expect({field_expr}).not.toContain({js_val});");
999            }
1000        }
1001        "not_empty" => {
1002            // Use null-coalescing for optional fields to handle null/undefined values.
1003            let resolved = assertion.field.as_deref().unwrap_or("");
1004            if !resolved.is_empty() && field_resolver.is_optional(field_resolver.resolve(resolved)) {
1005                let _ = writeln!(out, "    expect(({field_expr} ?? \"\").length).toBeGreaterThan(0);");
1006            } else {
1007                let _ = writeln!(out, "    expect({field_expr}.length).toBeGreaterThan(0);");
1008            }
1009        }
1010        "is_empty" => {
1011            // Use null-coalescing for optional string fields to handle null/undefined values.
1012            let resolved = assertion.field.as_deref().unwrap_or("");
1013            if !resolved.is_empty() && field_resolver.is_optional(field_resolver.resolve(resolved)) {
1014                let _ = writeln!(out, "    expect({field_expr} ?? \"\").toHaveLength(0);");
1015            } else {
1016                let _ = writeln!(out, "    expect({field_expr}).toHaveLength(0);");
1017            }
1018        }
1019        "contains_any" => {
1020            if let Some(values) = &assertion.values {
1021                let items: Vec<String> = values.iter().map(json_to_js).collect();
1022                let arr_str = items.join(", ");
1023                let _ = writeln!(
1024                    out,
1025                    "    expect([{arr_str}].some((v) => {field_expr}.includes(v))).toBe(true);"
1026                );
1027            }
1028        }
1029        "greater_than" => {
1030            if let Some(val) = &assertion.value {
1031                let js_val = json_to_js(val);
1032                let _ = writeln!(out, "    expect({field_expr}).toBeGreaterThan({js_val});");
1033            }
1034        }
1035        "less_than" => {
1036            if let Some(val) = &assertion.value {
1037                let js_val = json_to_js(val);
1038                let _ = writeln!(out, "    expect({field_expr}).toBeLessThan({js_val});");
1039            }
1040        }
1041        "greater_than_or_equal" => {
1042            if let Some(val) = &assertion.value {
1043                let js_val = json_to_js(val);
1044                let _ = writeln!(out, "    expect({field_expr}).toBeGreaterThanOrEqual({js_val});");
1045            }
1046        }
1047        "less_than_or_equal" => {
1048            if let Some(val) = &assertion.value {
1049                let js_val = json_to_js(val);
1050                let _ = writeln!(out, "    expect({field_expr}).toBeLessThanOrEqual({js_val});");
1051            }
1052        }
1053        "starts_with" => {
1054            if let Some(expected) = &assertion.value {
1055                let js_val = json_to_js(expected);
1056                // Use null-coalescing for optional fields to handle null/undefined values.
1057                let resolved = assertion.field.as_deref().unwrap_or("");
1058                if !resolved.is_empty() && field_resolver.is_optional(field_resolver.resolve(resolved)) {
1059                    let _ = writeln!(
1060                        out,
1061                        "    expect(({field_expr} ?? \"\").startsWith({js_val})).toBe(true);"
1062                    );
1063                } else {
1064                    let _ = writeln!(out, "    expect({field_expr}.startsWith({js_val})).toBe(true);");
1065                }
1066            }
1067        }
1068        "count_min" => {
1069            if let Some(val) = &assertion.value {
1070                if let Some(n) = val.as_u64() {
1071                    let _ = writeln!(out, "    expect({field_expr}.length).toBeGreaterThanOrEqual({n});");
1072                }
1073            }
1074        }
1075        "count_equals" => {
1076            if let Some(val) = &assertion.value {
1077                if let Some(n) = val.as_u64() {
1078                    let _ = writeln!(out, "    expect({field_expr}.length).toBe({n});");
1079                }
1080            }
1081        }
1082        "is_true" => {
1083            let _ = writeln!(out, "    expect({field_expr}).toBe(true);");
1084        }
1085        "is_false" => {
1086            let _ = writeln!(out, "    expect({field_expr}).toBe(false);");
1087        }
1088        "method_result" => {
1089            if let Some(method_name) = &assertion.method {
1090                let call_expr = build_ts_method_call(result_var, method_name, assertion.args.as_ref());
1091                let check = assertion.check.as_deref().unwrap_or("is_true");
1092                match check {
1093                    "equals" => {
1094                        if let Some(val) = &assertion.value {
1095                            let js_val = json_to_js(val);
1096                            let _ = writeln!(out, "    expect({call_expr}).toBe({js_val});");
1097                        }
1098                    }
1099                    "is_true" => {
1100                        let _ = writeln!(out, "    expect({call_expr}).toBe(true);");
1101                    }
1102                    "is_false" => {
1103                        let _ = writeln!(out, "    expect({call_expr}).toBe(false);");
1104                    }
1105                    "greater_than_or_equal" => {
1106                        if let Some(val) = &assertion.value {
1107                            let n = val.as_u64().unwrap_or(0);
1108                            let _ = writeln!(out, "    expect({call_expr}).toBeGreaterThanOrEqual({n});");
1109                        }
1110                    }
1111                    "count_min" => {
1112                        if let Some(val) = &assertion.value {
1113                            let n = val.as_u64().unwrap_or(0);
1114                            let _ = writeln!(out, "    expect({call_expr}.length).toBeGreaterThanOrEqual({n});");
1115                        }
1116                    }
1117                    "contains" => {
1118                        if let Some(val) = &assertion.value {
1119                            let js_val = json_to_js(val);
1120                            let _ = writeln!(out, "    expect({call_expr}).toContain({js_val});");
1121                        }
1122                    }
1123                    "is_error" => {
1124                        let _ = writeln!(out, "    expect(() => {{ {call_expr}; }}).toThrow();");
1125                    }
1126                    other_check => {
1127                        panic!("TypeScript e2e generator: unsupported method_result check type: {other_check}");
1128                    }
1129                }
1130            } else {
1131                panic!("TypeScript e2e generator: method_result assertion missing 'method' field");
1132            }
1133        }
1134        "min_length" => {
1135            if let Some(val) = &assertion.value {
1136                if let Some(n) = val.as_u64() {
1137                    let _ = writeln!(out, "    expect({field_expr}.length).toBeGreaterThanOrEqual({n});");
1138                }
1139            }
1140        }
1141        "max_length" => {
1142            if let Some(val) = &assertion.value {
1143                if let Some(n) = val.as_u64() {
1144                    let _ = writeln!(out, "    expect({field_expr}.length).toBeLessThanOrEqual({n});");
1145                }
1146            }
1147        }
1148        "ends_with" => {
1149            if let Some(expected) = &assertion.value {
1150                let js_val = json_to_js(expected);
1151                let _ = writeln!(out, "    expect({field_expr}.endsWith({js_val})).toBe(true);");
1152            }
1153        }
1154        "matches_regex" => {
1155            if let Some(expected) = &assertion.value {
1156                if let Some(pattern) = expected.as_str() {
1157                    let _ = writeln!(out, "    expect({field_expr}).toMatch(/{pattern}/);");
1158                }
1159            }
1160        }
1161        "not_error" => {
1162            // No-op — if we got here, the call succeeded (it would have thrown).
1163        }
1164        "error" => {
1165            // Handled at the test level (early return above).
1166        }
1167        other => {
1168            panic!("TypeScript e2e generator: unsupported assertion type: {other}");
1169        }
1170    }
1171}
1172
1173/// Build a TypeScript call expression for a method_result assertion on a tree-sitter Tree.
1174/// Maps method names to the appropriate TypeScript function calls or property accesses.
1175fn build_ts_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
1176    match method_name {
1177        "root_child_count" => format!("{result_var}.rootNode.childCount"),
1178        "root_node_type" => format!("{result_var}.rootNode.type"),
1179        "named_children_count" => format!("{result_var}.rootNode.namedChildCount"),
1180        "has_error_nodes" => format!("treeHasErrorNodes({result_var})"),
1181        "error_count" | "tree_error_count" => format!("treeErrorCount({result_var})"),
1182        "tree_to_sexp" => format!("treeToSexp({result_var})"),
1183        "contains_node_type" => {
1184            let node_type = args
1185                .and_then(|a| a.get("node_type"))
1186                .and_then(|v| v.as_str())
1187                .unwrap_or("");
1188            format!("treeContainsNodeType({result_var}, \"{node_type}\")")
1189        }
1190        "find_nodes_by_type" => {
1191            let node_type = args
1192                .and_then(|a| a.get("node_type"))
1193                .and_then(|v| v.as_str())
1194                .unwrap_or("");
1195            format!("findNodesByType({result_var}, \"{node_type}\")")
1196        }
1197        "run_query" => {
1198            let query_source = args
1199                .and_then(|a| a.get("query_source"))
1200                .and_then(|v| v.as_str())
1201                .unwrap_or("");
1202            let language = args
1203                .and_then(|a| a.get("language"))
1204                .and_then(|v| v.as_str())
1205                .unwrap_or("");
1206            format!("runQuery({result_var}, \"{language}\", \"{query_source}\", source)")
1207        }
1208        _ => {
1209            if let Some(args_val) = args {
1210                let arg_str = args_val
1211                    .as_object()
1212                    .map(|obj| {
1213                        obj.iter()
1214                            .map(|(k, v)| format!("{}: {}", k, json_to_js(v)))
1215                            .collect::<Vec<_>>()
1216                            .join(", ")
1217                    })
1218                    .unwrap_or_default();
1219                format!("{result_var}.{method_name}({arg_str})")
1220            } else {
1221                format!("{result_var}.{method_name}()")
1222            }
1223        }
1224    }
1225}
1226
1227/// Convert a `serde_json::Value` to a JavaScript literal string with camelCase object keys.
1228///
1229/// NAPI-RS bindings use camelCase for JavaScript field names. This variant converts
1230/// snake_case object keys (as written in fixture JSON) to camelCase so that the
1231/// generated config objects match the NAPI binding's expected field names.
1232fn json_to_js_camel(value: &serde_json::Value) -> String {
1233    match value {
1234        serde_json::Value::Object(map) => {
1235            let entries: Vec<String> = map
1236                .iter()
1237                .map(|(k, v)| {
1238                    let camel_key = snake_to_camel(k);
1239                    // Quote keys that aren't valid JS identifiers.
1240                    let key = if camel_key
1241                        .chars()
1242                        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
1243                        && !camel_key.starts_with(|c: char| c.is_ascii_digit())
1244                    {
1245                        camel_key.clone()
1246                    } else {
1247                        format!("\"{}\"", escape_js(&camel_key))
1248                    };
1249                    format!("{key}: {}", json_to_js_camel(v))
1250                })
1251                .collect();
1252            format!("{{ {} }}", entries.join(", "))
1253        }
1254        serde_json::Value::Array(arr) => {
1255            let items: Vec<String> = arr.iter().map(json_to_js_camel).collect();
1256            format!("[{}]", items.join(", "))
1257        }
1258        // Scalars and null delegate to the standard converter.
1259        other => json_to_js(other),
1260    }
1261}
1262
1263/// Convert a snake_case string to camelCase.
1264fn snake_to_camel(s: &str) -> String {
1265    let mut result = String::with_capacity(s.len());
1266    let mut capitalize_next = false;
1267    for ch in s.chars() {
1268        if ch == '_' {
1269            capitalize_next = true;
1270        } else if capitalize_next {
1271            result.extend(ch.to_uppercase());
1272            capitalize_next = false;
1273        } else {
1274            result.push(ch);
1275        }
1276    }
1277    result
1278}
1279
1280/// Convert a `serde_json::Value` to a JavaScript literal string.
1281fn json_to_js(value: &serde_json::Value) -> String {
1282    match value {
1283        serde_json::Value::String(s) => {
1284            let expanded = expand_fixture_templates(s);
1285            format!("\"{}\"", escape_js(&expanded))
1286        }
1287        serde_json::Value::Bool(b) => b.to_string(),
1288        serde_json::Value::Number(n) => {
1289            // For integers outside JS safe range, emit as string to avoid precision loss.
1290            if let Some(i) = n.as_i64() {
1291                if !(-9_007_199_254_740_991..=9_007_199_254_740_991).contains(&i) {
1292                    return format!("Number(\"{i}\")");
1293                }
1294            }
1295            if let Some(u) = n.as_u64() {
1296                if u > 9_007_199_254_740_991 {
1297                    return format!("Number(\"{u}\")");
1298                }
1299            }
1300            n.to_string()
1301        }
1302        serde_json::Value::Null => "null".to_string(),
1303        serde_json::Value::Array(arr) => {
1304            let items: Vec<String> = arr.iter().map(json_to_js).collect();
1305            format!("[{}]", items.join(", "))
1306        }
1307        serde_json::Value::Object(map) => {
1308            let entries: Vec<String> = map
1309                .iter()
1310                .map(|(k, v)| {
1311                    // Quote keys that aren't valid JS identifiers (contain hyphens, spaces, etc.)
1312                    let key = if k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
1313                        && !k.starts_with(|c: char| c.is_ascii_digit())
1314                    {
1315                        k.clone()
1316                    } else {
1317                        format!("\"{}\"", escape_js(k))
1318                    };
1319                    format!("{key}: {}", json_to_js(v))
1320                })
1321                .collect();
1322            format!("{{ {} }}", entries.join(", "))
1323        }
1324    }
1325}
1326
1327// ---------------------------------------------------------------------------
1328// Visitor generation
1329// ---------------------------------------------------------------------------
1330
1331/// Build a TypeScript visitor object and add setup line. Returns the visitor variable name.
1332fn build_typescript_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1333    use std::fmt::Write as FmtWrite;
1334    let mut visitor_obj = String::new();
1335    let _ = writeln!(visitor_obj, "{{");
1336    for (method_name, action) in &visitor_spec.callbacks {
1337        emit_typescript_visitor_method(&mut visitor_obj, method_name, action);
1338    }
1339    let _ = writeln!(visitor_obj, "    }}");
1340
1341    setup_lines.push(format!("const _testVisitor = {visitor_obj}"));
1342    "_testVisitor".to_string()
1343}
1344
1345/// Emit a TypeScript visitor method for a callback action.
1346fn emit_typescript_visitor_method(out: &mut String, method_name: &str, action: &CallbackAction) {
1347    use std::fmt::Write as FmtWrite;
1348
1349    let camel_method = to_camel_case(method_name);
1350    let params = match method_name {
1351        "visit_link" => "ctx, href, text, title",
1352        "visit_image" => "ctx, src, alt, title",
1353        "visit_heading" => "ctx, level, text, id",
1354        "visit_code_block" => "ctx, lang, code",
1355        "visit_code_inline"
1356        | "visit_strong"
1357        | "visit_emphasis"
1358        | "visit_strikethrough"
1359        | "visit_underline"
1360        | "visit_subscript"
1361        | "visit_superscript"
1362        | "visit_mark"
1363        | "visit_button"
1364        | "visit_summary"
1365        | "visit_figcaption"
1366        | "visit_definition_term"
1367        | "visit_definition_description" => "ctx, text",
1368        "visit_text" => "ctx, text",
1369        "visit_list_item" => "ctx, ordered, marker, text",
1370        "visit_blockquote" => "ctx, content, depth",
1371        "visit_table_row" => "ctx, cells, isHeader",
1372        "visit_custom_element" => "ctx, tagName, html",
1373        "visit_form" => "ctx, actionUrl, method",
1374        "visit_input" => "ctx, inputType, name, value",
1375        "visit_audio" | "visit_video" | "visit_iframe" => "ctx, src",
1376        "visit_details" => "ctx, isOpen",
1377        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "ctx, output",
1378        "visit_list_start" => "ctx, ordered",
1379        "visit_list_end" => "ctx, ordered, output",
1380        _ => "ctx",
1381    };
1382
1383    let _ = writeln!(
1384        out,
1385        "    {camel_method}({params}): string | {{{{ custom: string }}}} {{"
1386    );
1387    match action {
1388        CallbackAction::Skip => {
1389            let _ = writeln!(out, "        return \"skip\";");
1390        }
1391        CallbackAction::Continue => {
1392            let _ = writeln!(out, "        return \"continue\";");
1393        }
1394        CallbackAction::PreserveHtml => {
1395            let _ = writeln!(out, "        return \"preserve_html\";");
1396        }
1397        CallbackAction::Custom { output } => {
1398            let escaped = escape_js(output);
1399            let _ = writeln!(out, "        return {{ custom: {escaped} }};");
1400        }
1401        CallbackAction::CustomTemplate { template } => {
1402            let _ = writeln!(out, "        return {{ custom: `{template}` }};");
1403        }
1404    }
1405    let _ = writeln!(out, "    }},");
1406}
1407
1408/// Convert snake_case to camelCase for method names.
1409fn to_camel_case(snake: &str) -> String {
1410    use heck::ToLowerCamelCase;
1411    snake.to_lower_camel_case()
1412}