alef_e2e/codegen/swift.rs
1//! Swift e2e test generator using XCTest.
2//!
3//! Generates a standalone Swift package at `e2e/swift_e2e/` that depends on the
4//! binding at `packages/swift/` via `.package(path:)`.
5//!
6//! IMPORTANT: SwiftPM 6.0 derives the identity of path-based dependencies from
7//! the path's *basename* and ignores any explicit `name:` override. If the
8//! consumer (`e2e/swift/`) and the dep (`packages/swift/`) share the same path
9//! basename `swift`, SwiftPM treats them as the same package and fails
10//! resolution with: `product '<X>' required by package 'swift' target '...' not
11//! found in package 'swift'`. The e2e package is therefore emitted under
12//! `swift_e2e/` to guarantee a distinct identity from any sibling
13//! `packages/swift/` dep.
14
15use crate::config::E2eConfig;
16use crate::escape::{escape_java as escape_swift_str, expand_fixture_templates, sanitize_filename, sanitize_ident};
17use crate::field_access::{FieldResolver, SwiftFirstClassMap};
18use crate::fixture::{Assertion, Fixture, FixtureGroup, ValidationErrorExpectation};
19use alef_codegen::keywords::swift_ident;
20use alef_core::backend::GeneratedFile;
21use alef_core::config::ResolvedCrateConfig;
22use alef_core::hash::{self, CommentStyle};
23use alef_core::template_versions::toolchain;
24use anyhow::Result;
25use heck::{ToLowerCamelCase, ToSnakeCase, ToUpperCamelCase};
26use std::collections::HashMap;
27use std::collections::HashSet;
28use std::fmt::Write as FmtWrite;
29use std::path::PathBuf;
30
31use super::E2eCodegen;
32
33// Empty `result_field_accessor` map shared across calls that don't configure
34// one. Using a `OnceLock` lets `render_test_method` hand out a stable
35// reference without rebuilding the empty `HashMap` for every fixture.
36static EMPTY_FIELD_ACCESSOR_MAP: std::sync::OnceLock<HashMap<String, String>> = std::sync::OnceLock::new();
37
38fn empty_field_accessor_map() -> &'static HashMap<String, String> {
39 EMPTY_FIELD_ACCESSOR_MAP.get_or_init(HashMap::new)
40}
41use super::client;
42
43/// Swift e2e code generator.
44pub struct SwiftE2eCodegen;
45
46impl E2eCodegen for SwiftE2eCodegen {
47 fn generate(
48 &self,
49 groups: &[FixtureGroup],
50 e2e_config: &E2eConfig,
51 config: &ResolvedCrateConfig,
52 type_defs: &[alef_core::ir::TypeDef],
53 enums: &[alef_core::ir::EnumDef],
54 ) -> Result<Vec<GeneratedFile>> {
55 let lang = self.language_name();
56 // Emit under `<output>/swift_e2e/` so the consumer's SwiftPM identity
57 // (derived from path basename) does not collide with the dep at
58 // `packages/swift/` (also basename `swift`). SwiftPM 6.0 deprecated the
59 // `name:` parameter on `.package(path:)` and uses the path basename as
60 // the package's identity unconditionally, so disambiguation must happen
61 // at the filesystem level. Consumers of the alef-emitted e2e must
62 // `cd e2e/swift_e2e/` to run `swift test`.
63 let output_base = PathBuf::from(e2e_config.effective_output()).join("swift_e2e");
64
65 let mut files = Vec::new();
66
67 // Resolve call config with overrides.
68 let call = &e2e_config.call;
69 let overrides = call.overrides.get(lang);
70 let function_name = overrides
71 .and_then(|o| o.function.as_ref())
72 .cloned()
73 .unwrap_or_else(|| call.function.clone());
74 let result_var = &call.result_var;
75 let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
76
77 // Resolve package config.
78 let swift_pkg = e2e_config.resolve_package("swift");
79 let pkg_name = swift_pkg
80 .as_ref()
81 .and_then(|p| p.name.as_ref())
82 .cloned()
83 .unwrap_or_else(|| config.name.to_upper_camel_case());
84 let pkg_path = swift_pkg
85 .as_ref()
86 .and_then(|p| p.path.as_ref())
87 .cloned()
88 .unwrap_or_else(|| "../../packages/swift".to_string());
89 let pkg_version = swift_pkg
90 .as_ref()
91 .and_then(|p| p.version.as_ref())
92 .cloned()
93 .or_else(|| config.resolved_version())
94 .unwrap_or_else(|| "0.1.0".to_string());
95
96 // The Swift module name: UpperCamelCase of the package name.
97 let module_name = pkg_name.as_str();
98
99 // Resolve the registry URL: derive from the configured repository when
100 // available (with a `.git` suffix per SwiftPM convention). Falls back
101 // to a vendor-neutral placeholder when no repo is configured.
102 let registry_url = config
103 .try_github_repo()
104 .map(|repo| {
105 let base = repo.trim_end_matches('/').trim_end_matches(".git");
106 format!("{base}.git")
107 })
108 .unwrap_or_else(|_| format!("https://example.invalid/{module_name}.git"));
109
110 // Generate Package.swift for the standalone e2e consumer at
111 // `<output>/swift_e2e/`. `swift test` is run from that directory.
112 files.push(GeneratedFile {
113 path: output_base.join("Package.swift"),
114 content: render_package_swift(module_name, ®istry_url, &pkg_path, &pkg_version, e2e_config.dep_mode),
115 generated_header: false,
116 });
117
118 // Tests are placed alongside Package.swift under `<output>/swift_e2e/Tests/...`.
119 let tests_base = output_base.clone();
120
121 // Build the Swift first-class/opaque classification map for per-segment
122 // dispatch in `render_swift_with_first_class_map`. A TypeDef is treated
123 // as first-class (Codable struct → property access) when it's not opaque,
124 // has serde derives, and every binding field is primitive/optional. This
125 // mirrors `can_emit_first_class_struct` in alef-backend-swift.
126 let swift_first_class_map = build_swift_first_class_map(type_defs, enums, e2e_config);
127
128 let swift_first_class_map_ref = swift_first_class_map;
129
130 // Resolve client_factory override for swift (enables client-instance dispatch).
131 let client_factory: Option<&str> = overrides.and_then(|o| o.client_factory.as_deref());
132
133 // Emit a shared TestHelpers.swift that gives `RustString` a
134 // `CustomStringConvertible` conformance. swift-bridge generates the
135 // `RustString` opaque class but does NOT make it print readably — so
136 // any error thrown from a bridge function (the `throw RustString(...)`
137 // branches) surfaces in XCTest's failure output as the bare type name
138 // `"RustBridge.RustString"`, with the actual Rust error message
139 // hidden inside the unprinted instance. The retroactive extension
140 // here pulls `.toString()` into `.description` so failures print
141 // something diagnostic. Single file per test target; idempotent
142 // across regens.
143 files.push(GeneratedFile {
144 path: tests_base
145 .join("Tests")
146 .join(format!("{module_name}E2ETests"))
147 .join("TestHelpers.swift"),
148 content: render_test_helpers_swift(),
149 generated_header: true,
150 });
151
152 // One test file per fixture group.
153 for group in groups {
154 let active: Vec<&Fixture> = group
155 .fixtures
156 .iter()
157 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
158 .collect();
159
160 if active.is_empty() {
161 continue;
162 }
163
164 let class_name = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
165 let filename = format!("{class_name}.swift");
166 let content = render_test_file(
167 &group.category,
168 &active,
169 e2e_config,
170 module_name,
171 &class_name,
172 &function_name,
173 result_var,
174 &e2e_config.call.args,
175 result_is_simple,
176 client_factory,
177 &swift_first_class_map_ref,
178 );
179 files.push(GeneratedFile {
180 path: tests_base
181 .join("Tests")
182 .join(format!("{module_name}E2ETests"))
183 .join(filename),
184 content,
185 generated_header: true,
186 });
187 }
188
189 Ok(files)
190 }
191
192 fn language_name(&self) -> &'static str {
193 "swift"
194 }
195}
196
197// ---------------------------------------------------------------------------
198// Rendering
199// ---------------------------------------------------------------------------
200
201/// Directive telling Apple's `swift-format` to skip the file entirely.
202///
203/// The e2e generator emits Swift source with 4-space indentation, fixed import
204/// order (`XCTest, Foundation, <Module>, RustBridge`) and unwrapped long lines
205/// — all of which violate `swift-format`'s defaults (2-space indent, sorted
206/// imports, 100-char line width). Reformatting after every regen would force
207/// every consumer repo to either bake `swift-format` into their pre-commit set
208/// or eat noisy diffs. Marking the files as ignored is the same workaround the
209/// Swift binding backend uses for `HtmlToMarkdown.swift` (see
210/// `alef-backend-swift/src/gen_bindings.rs`) and keeps the file
211/// byte-identical between `alef generate` runs and `swift-format` hooks.
212const SWIFT_FORMAT_IGNORE_DIRECTIVE: &str = "// swift-format-ignore-file\n\n";
213
214/// Render the shared `TestHelpers.swift` file emitted into each Swift e2e
215/// test target. Adds a `CustomStringConvertible` conformance to swift-bridge's
216/// `RustString` so error messages from bridge throws print their actual Rust
217/// content instead of the bare class name.
218fn render_test_helpers_swift() -> String {
219 let header = hash::header(CommentStyle::DoubleSlash);
220 let ignore = SWIFT_FORMAT_IGNORE_DIRECTIVE;
221 format!(
222 r#"{header}{ignore}import Foundation
223import RustBridge
224
225// Make `RustString` print its content in XCTest failure output. Without this,
226// every error thrown from the swift-bridge layer surfaces as
227// `caught error: "RustBridge.RustString"` with the actual message hidden
228// inside the opaque class instance. The `@retroactive` keyword acknowledges
229// that the conformed-to protocol (`CustomStringConvertible`) and the
230// conforming type (`RustString`) both live outside this module — required by
231// Swift 6 to silence the retroactive-conformance warning. swift-bridge does
232// not give `RustString` a `description` of its own, so there is no conflict.
233extension RustString: @retroactive CustomStringConvertible {{
234 public var description: String {{ self.toString() }}
235}}
236"#
237 )
238}
239
240fn render_package_swift(
241 module_name: &str,
242 registry_url: &str,
243 pkg_path: &str,
244 pkg_version: &str,
245 dep_mode: crate::config::DependencyMode,
246) -> String {
247 let min_macos = toolchain::SWIFT_MIN_MACOS;
248
249 // For local deps SwiftPM identity = last path component (e.g. "../../packages/swift" → "swift").
250 // For registry deps identity is inferred from the URL.
251 // Use explicit .product(name:package:) to avoid ambiguity under tools-version 6.0.
252 let (dep_block, product_dep) = match dep_mode {
253 crate::config::DependencyMode::Registry => {
254 let dep = format!(r#" .package(url: "{registry_url}", from: "{pkg_version}")"#);
255 let pkg_id = registry_url
256 .trim_end_matches('/')
257 .trim_end_matches(".git")
258 .split('/')
259 .next_back()
260 .unwrap_or(module_name);
261 let prod = format!(r#".product(name: "{module_name}", package: "{pkg_id}")"#);
262 (dep, prod)
263 }
264 crate::config::DependencyMode::Local => {
265 // SwiftPM 6.0 deprecated the `name:` parameter on `.package(path:)`:
266 // package identity is derived from the path's last component, ignoring
267 // any explicit `name:`. The `.product(package:)` reference must therefore
268 // match that identity (the path basename), not the dep's declared
269 // `Package(name:)`. The product `name:` still matches the library
270 // declared in the dep's manifest (e.g. `.library(name: "Kreuzberg")`).
271 let pkg_id = pkg_path.trim_end_matches('/').rsplit('/').next().unwrap_or(module_name);
272 let dep = format!(r#" .package(path: "{pkg_path}")"#);
273 let prod = format!(r#".product(name: "{module_name}", package: "{pkg_id}")"#);
274 (dep, prod)
275 }
276 };
277 // SwiftPM platform enums use the major version only (.v13, .v14, ...);
278 // strip patch components to match the scaffold's `Package.swift`.
279 let min_macos_major = min_macos.split('.').next().unwrap_or(min_macos);
280 let min_ios = toolchain::SWIFT_MIN_IOS;
281 let min_ios_major = min_ios.split('.').next().unwrap_or(min_ios);
282 // The consumer's minimum iOS must be >= the dep's minimum iOS or SwiftPM hides
283 // the product as platform-incompatible. Use the same constant the swift backend
284 // emits into the dep's Package.swift.
285 format!(
286 r#"// swift-tools-version: 6.0
287import PackageDescription
288
289let package = Package(
290 name: "E2eSwift",
291 platforms: [
292 .macOS(.v{min_macos_major}),
293 .iOS(.v{min_ios_major}),
294 ],
295 dependencies: [
296{dep_block},
297 ],
298 targets: [
299 .testTarget(
300 name: "{module_name}E2ETests",
301 dependencies: [{product_dep}]
302 ),
303 ]
304)
305"#
306 )
307}
308
309#[allow(clippy::too_many_arguments)]
310fn render_test_file(
311 category: &str,
312 fixtures: &[&Fixture],
313 e2e_config: &E2eConfig,
314 module_name: &str,
315 class_name: &str,
316 function_name: &str,
317 result_var: &str,
318 args: &[crate::config::ArgMapping],
319 result_is_simple: bool,
320 client_factory: Option<&str>,
321 swift_first_class_map: &SwiftFirstClassMap,
322) -> String {
323 // Detect whether any fixture in this group uses a file_path or bytes arg — if so
324 // the test class chdir's to <repo>/test_documents at setUp time so the
325 // fixture-relative paths in test bodies (e.g. "docx/fake.docx") resolve correctly.
326 // The Swift binding's `extractBytes`/`extractFile` e2e wrappers consult
327 // `FIXTURES_DIR` first, otherwise resolve against the current directory.
328 // Mirrors the Ruby/Python conftest pattern that chdirs to test_documents.
329 let needs_chdir = fixtures.iter().any(|f| {
330 let call_config =
331 e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.id, &f.resolved_category(), &f.tags, &f.input);
332 call_config
333 .args
334 .iter()
335 .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
336 });
337
338 let mut out = String::new();
339 out.push_str(&hash::header(CommentStyle::DoubleSlash));
340 out.push_str(SWIFT_FORMAT_IGNORE_DIRECTIVE);
341 let _ = writeln!(out, "import XCTest");
342 let _ = writeln!(out, "import Foundation");
343 let _ = writeln!(out, "import {module_name}");
344 let _ = writeln!(out, "import RustBridge");
345 let _ = writeln!(out);
346 let _ = writeln!(out, "/// E2e tests for category: {category}.");
347 let _ = writeln!(out, "final class {class_name}: XCTestCase {{");
348
349 if needs_chdir {
350 // Chdir once at class setUp so all fixture file_path arguments resolve relative
351 // to the repository's test_documents directory.
352 //
353 // #filePath = <repo>/e2e/swift_e2e/Tests/<Module>E2ETests/<Class>.swift
354 // 5 deletingLastPathComponent() calls climb to the repo root before appending
355 // "test_documents". Mirrors the Ruby/Python conftest pattern that chdirs to
356 // test_documents.
357 let _ = writeln!(out, " override class func setUp() {{");
358 let _ = writeln!(out, " super.setUp()");
359 let _ = writeln!(out, " let _testDocs = URL(fileURLWithPath: #filePath)");
360 let _ = writeln!(out, " .deletingLastPathComponent() // <Module>Tests/");
361 let _ = writeln!(out, " .deletingLastPathComponent() // Tests/");
362 let _ = writeln!(out, " .deletingLastPathComponent() // swift/");
363 let _ = writeln!(out, " .deletingLastPathComponent() // packages/");
364 let _ = writeln!(out, " .deletingLastPathComponent() // <repo root>");
365 let _ = writeln!(
366 out,
367 " .appendingPathComponent(\"{}\")",
368 e2e_config.test_documents_dir
369 );
370 let _ = writeln!(
371 out,
372 " if FileManager.default.fileExists(atPath: _testDocs.path) {{"
373 );
374 let _ = writeln!(
375 out,
376 " FileManager.default.changeCurrentDirectoryPath(_testDocs.path)"
377 );
378 let _ = writeln!(out, " }}");
379 let _ = writeln!(out, " }}");
380 let _ = writeln!(out);
381 }
382
383 for fixture in fixtures {
384 if fixture.is_http_test() {
385 render_http_test_method(&mut out, fixture);
386 } else {
387 render_test_method(
388 &mut out,
389 fixture,
390 e2e_config,
391 function_name,
392 result_var,
393 args,
394 result_is_simple,
395 client_factory,
396 swift_first_class_map,
397 module_name,
398 );
399 }
400 let _ = writeln!(out);
401 }
402
403 let _ = writeln!(out, "}}");
404 out
405}
406
407// ---------------------------------------------------------------------------
408// HTTP test rendering — TestClientRenderer impl + thin driver wrapper
409// ---------------------------------------------------------------------------
410
411/// Renderer that emits XCTest `func test...() throws` methods using `URLSession`
412/// against the mock server (`ProcessInfo.processInfo.environment["MOCK_SERVER_URL"]`).
413struct SwiftTestClientRenderer;
414
415impl client::TestClientRenderer for SwiftTestClientRenderer {
416 fn language_name(&self) -> &'static str {
417 "swift"
418 }
419
420 fn sanitize_test_name(&self, id: &str) -> String {
421 // Swift test methods are `func testFoo()` — upper-camel-case after "test".
422 sanitize_ident(id).to_upper_camel_case()
423 }
424
425 /// Emit `func test{FnName}() throws {` (or a skip stub when the fixture is skipped).
426 ///
427 /// XCTest has no first-class skip annotation prior to Swift Testing (`@Test`).
428 /// For skipped fixtures we emit `try XCTSkipIf(true, reason)` inside the
429 /// function body so XCTest records them as skipped rather than omitting them.
430 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
431 let _ = writeln!(out, " /// {description}");
432 let _ = writeln!(out, " func test{fn_name}() throws {{");
433 if let Some(reason) = skip_reason {
434 let escaped = escape_swift(reason);
435 let _ = writeln!(out, " try XCTSkipIf(true, \"{escaped}\")");
436 }
437 }
438
439 fn render_test_close(&self, out: &mut String) {
440 let _ = writeln!(out, " }}");
441 }
442
443 /// Emit a synchronous `URLSession` round-trip to the mock server.
444 ///
445 /// `ProcessInfo.processInfo.environment["MOCK_SERVER_URL"]!` provides the base
446 /// URL; the fixture path is appended directly. The call uses a semaphore so the
447 /// generated test body stays synchronous (compatible with `throws` functions —
448 /// no `async` XCTest support needed).
449 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
450 let method = ctx.method.to_uppercase();
451 let fixture_path = escape_swift(ctx.path);
452
453 let _ = writeln!(
454 out,
455 " let _baseURL = ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]!"
456 );
457 let _ = writeln!(
458 out,
459 " var _req = URLRequest(url: URL(string: _baseURL + \"{fixture_path}\")!)"
460 );
461 let _ = writeln!(out, " _req.httpMethod = \"{method}\"");
462
463 // Headers
464 let mut header_pairs: Vec<(&String, &String)> = ctx.headers.iter().collect();
465 header_pairs.sort_by_key(|(k, _)| k.as_str());
466 for (k, v) in &header_pairs {
467 let expanded_v = expand_fixture_templates(v);
468 let ek = escape_swift(k);
469 let ev = escape_swift(&expanded_v);
470 let _ = writeln!(out, " _req.setValue(\"{ev}\", forHTTPHeaderField: \"{ek}\")");
471 }
472
473 // Body
474 if let Some(body) = ctx.body {
475 let json_str = serde_json::to_string(body).unwrap_or_default();
476 let escaped_body = escape_swift(&json_str);
477 let _ = writeln!(out, " _req.httpBody = \"{escaped_body}\".data(using: .utf8)");
478 let _ = writeln!(
479 out,
480 " _req.setValue(\"application/json\", forHTTPHeaderField: \"Content-Type\")"
481 );
482 }
483
484 let _ = writeln!(out, " var {}: HTTPURLResponse?", ctx.response_var);
485 let _ = writeln!(out, " var _responseData: Data?");
486 let _ = writeln!(out, " let _sema = DispatchSemaphore(value: 0)");
487 let _ = writeln!(
488 out,
489 " URLSession.shared.dataTask(with: _req) {{ data, resp, _ in"
490 );
491 let _ = writeln!(out, " {} = resp as? HTTPURLResponse", ctx.response_var);
492 let _ = writeln!(out, " _responseData = data");
493 let _ = writeln!(out, " _sema.signal()");
494 let _ = writeln!(out, " }}.resume()");
495 let _ = writeln!(out, " _sema.wait()");
496 let _ = writeln!(out, " let _resp = try XCTUnwrap({})", ctx.response_var);
497 }
498
499 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
500 let _ = writeln!(out, " XCTAssertEqual(_resp.statusCode, {status})");
501 }
502
503 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
504 let lower_name = name.to_lowercase();
505 let header_expr = format!("_resp.value(forHTTPHeaderField: \"{}\")", escape_swift(&lower_name));
506 match expected {
507 "<<present>>" => {
508 let _ = writeln!(out, " XCTAssertNotNil({header_expr})");
509 }
510 "<<absent>>" => {
511 let _ = writeln!(out, " XCTAssertNil({header_expr})");
512 }
513 "<<uuid>>" => {
514 let _ = writeln!(out, " let _hdrVal_{lower_name} = try XCTUnwrap({header_expr})");
515 let _ = writeln!(
516 out,
517 " XCTAssertNotNil(_hdrVal_{lower_name}.range(of: #\"^[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}$\"#, options: .regularExpression))"
518 );
519 }
520 exact => {
521 let escaped = escape_swift(exact);
522 let _ = writeln!(out, " XCTAssertEqual({header_expr}, \"{escaped}\")");
523 }
524 }
525 }
526
527 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
528 if let serde_json::Value::String(s) = expected {
529 let escaped = escape_swift(s);
530 let _ = writeln!(
531 out,
532 " let _bodyStr = String(data: try XCTUnwrap(_responseData), encoding: .utf8) ?? \"\""
533 );
534 let _ = writeln!(
535 out,
536 " XCTAssertEqual(_bodyStr.trimmingCharacters(in: .whitespacesAndNewlines), \"{escaped}\")"
537 );
538 } else {
539 let json_str = serde_json::to_string(expected).unwrap_or_default();
540 let escaped = escape_swift(&json_str);
541 let _ = writeln!(out, " let _bodyData = try XCTUnwrap(_responseData)");
542 let _ = writeln!(
543 out,
544 " let _expected = try JSONSerialization.jsonObject(with: \"{escaped}\".data(using: .utf8)!)"
545 );
546 let _ = writeln!(
547 out,
548 " let _actual = try JSONSerialization.jsonObject(with: _bodyData)"
549 );
550 let _ = writeln!(
551 out,
552 " XCTAssertEqual(NSDictionary(dictionary: _expected as? [String: AnyHashable] ?? [:]), NSDictionary(dictionary: _actual as? [String: AnyHashable] ?? [:]))"
553 );
554 }
555 }
556
557 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
558 if let Some(obj) = expected.as_object() {
559 let _ = writeln!(out, " let _bodyData = try XCTUnwrap(_responseData)");
560 let _ = writeln!(
561 out,
562 " let _bodyObj = try XCTUnwrap(try JSONSerialization.jsonObject(with: _bodyData) as? [String: Any])"
563 );
564 for (key, val) in obj {
565 let escaped_key = escape_swift(key);
566 let swift_val = json_to_swift(val);
567 let _ = writeln!(
568 out,
569 " XCTAssertEqual(_bodyObj[\"{escaped_key}\"] as? AnyHashable, ({swift_val}) as AnyHashable)"
570 );
571 }
572 }
573 }
574
575 fn render_assert_validation_errors(
576 &self,
577 out: &mut String,
578 _response_var: &str,
579 errors: &[ValidationErrorExpectation],
580 ) {
581 let _ = writeln!(out, " let _bodyData = try XCTUnwrap(_responseData)");
582 let _ = writeln!(
583 out,
584 " let _bodyObj = try XCTUnwrap(try JSONSerialization.jsonObject(with: _bodyData) as? [String: Any])"
585 );
586 let _ = writeln!(
587 out,
588 " let _errors = _bodyObj[\"errors\"] as? [[String: Any]] ?? []"
589 );
590 for ve in errors {
591 let escaped_msg = escape_swift(&ve.msg);
592 let _ = writeln!(
593 out,
594 " XCTAssertTrue(_errors.contains(where: {{ ($0[\"msg\"] as? String)?.contains(\"{escaped_msg}\") == true }}), \"expected validation error: {escaped_msg}\")"
595 );
596 }
597 }
598}
599
600/// Render an XCTest method for an HTTP server fixture via the shared driver.
601///
602/// HTTP 101 (WebSocket upgrade) is emitted as a skip stub because `URLSession`
603/// cannot handle Upgrade responses.
604fn render_http_test_method(out: &mut String, fixture: &Fixture) {
605 let Some(http) = &fixture.http else {
606 return;
607 };
608
609 // HTTP 101 (WebSocket upgrade) — URLSession cannot handle upgrade responses.
610 if http.expected_response.status_code == 101 {
611 let method_name = sanitize_ident(&fixture.id).to_upper_camel_case();
612 let description = fixture.description.replace('"', "\\\"");
613 let _ = writeln!(out, " /// {description}");
614 let _ = writeln!(out, " func test{method_name}() throws {{");
615 let _ = writeln!(
616 out,
617 " try XCTSkipIf(true, \"HTTP 101 WebSocket upgrade cannot be tested via URLSession\")"
618 );
619 let _ = writeln!(out, " }}");
620 return;
621 }
622
623 client::http_call::render_http_test(out, &SwiftTestClientRenderer, fixture);
624}
625
626// ---------------------------------------------------------------------------
627// Function-call test rendering
628// ---------------------------------------------------------------------------
629
630#[allow(clippy::too_many_arguments)]
631fn render_test_method(
632 out: &mut String,
633 fixture: &Fixture,
634 e2e_config: &E2eConfig,
635 _function_name: &str,
636 _result_var: &str,
637 _args: &[crate::config::ArgMapping],
638 result_is_simple: bool,
639 global_client_factory: Option<&str>,
640 swift_first_class_map: &SwiftFirstClassMap,
641 module_name: &str,
642) {
643 // Resolve per-fixture call config.
644 let call_config = e2e_config.resolve_call_for_fixture(
645 fixture.call.as_deref(),
646 &fixture.id,
647 &fixture.resolved_category(),
648 &fixture.tags,
649 &fixture.input,
650 );
651 // Build per-call field resolver using the effective field sets for this call.
652 let call_field_resolver = FieldResolver::new_with_swift_first_class(
653 e2e_config.effective_fields(call_config),
654 e2e_config.effective_fields_optional(call_config),
655 e2e_config.effective_result_fields(call_config),
656 e2e_config.effective_fields_array(call_config),
657 e2e_config.effective_fields_method_calls(call_config),
658 &HashMap::new(),
659 swift_first_class_map.clone(),
660 );
661 let field_resolver = &call_field_resolver;
662 let enum_fields = e2e_config.effective_fields_enum(call_config);
663 let lang = "swift";
664 let call_overrides = call_config.overrides.get(lang);
665 let function_name = call_overrides
666 .and_then(|o| o.function.as_ref())
667 .cloned()
668 .unwrap_or_else(|| swift_ident(&call_config.function.to_lower_camel_case()));
669 // Per-call client_factory takes precedence over the global one.
670 let client_factory: Option<&str> = call_overrides
671 .and_then(|o| o.client_factory.as_deref())
672 .or(global_client_factory);
673 let result_var = &call_config.result_var;
674 let args = &call_config.args;
675 // Per-call flags: base call flag OR per-language override OR global flag.
676 // Also treat the call as simple when *any* language override marks it as bytes.
677 // Calls like `speech()` have `result_is_bytes = true` on C/C#/Java overrides but
678 // no explicit `result_is_simple` on the Swift override — yet the Swift binding
679 // returns `Data` directly (not a struct), so assertions must use `result.isEmpty`
680 // rather than `result.audio().toString().isEmpty`.
681 let result_is_bytes_any_lang =
682 call_config.result_is_bytes || call_config.overrides.values().any(|o| o.result_is_bytes);
683 let result_is_simple = call_config.result_is_simple
684 || call_overrides.is_some_and(|o| o.result_is_simple)
685 || result_is_simple
686 || result_is_bytes_any_lang;
687 let result_is_array = call_config.result_is_array;
688 // When the call returns `Option<T>` the Swift binding exposes the result as
689 // `Optional<…>` (e.g. `getEmbeddingPreset(...) -> EmbeddingPreset?`). Bare-result
690 // `is_empty`/`not_empty` assertions must use `XCTAssertNil` / `XCTAssertNotNil`
691 // rather than `.toString().isEmpty`, which is undefined on opaque optionals.
692 let result_is_option = call_config.result_is_option || call_overrides.is_some_and(|o| o.result_is_option);
693 let result_element_is_string =
694 call_config.result_element_is_string || call_overrides.is_some_and(|o| o.result_element_is_string);
695 // Per-language map of array-result-field → element accessor method (e.g.
696 // `structure → kind`). Empty map when no override is configured.
697 let result_field_accessor: &HashMap<String, String> = call_overrides
698 .map(|o| &o.result_field_accessor)
699 .unwrap_or_else(|| empty_field_accessor_map());
700
701 let method_name = fixture.id.to_upper_camel_case();
702 let description = &fixture.description;
703 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
704 let is_async = call_config.r#async;
705
706 // Streaming detection (call-level `streaming` opt-out is honored).
707 let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
708 let collect_snippet_opt = if is_streaming && !expects_error {
709 crate::codegen::streaming_assertions::StreamingFieldResolver::collect_snippet(lang, result_var, "chunks")
710 } else {
711 None
712 };
713 // When swift has streaming-virtual-field assertions but no collect snippet
714 // is available (the swift-bridge surface does not yet expose a typed
715 // `chatStream` async sequence we can drain into a typed
716 // `[ChatCompletionChunk]`), emit a skip stub rather than reference an
717 // undefined `chunks` local in the assertion expressions. This keeps the
718 // swift test target compiling while the binding catches up.
719 if is_streaming && !expects_error && collect_snippet_opt.is_none() {
720 if is_async {
721 let _ = writeln!(out, " func test{method_name}() async throws {{");
722 } else {
723 let _ = writeln!(out, " func test{method_name}() throws {{");
724 }
725 let _ = writeln!(out, " // {description}");
726 let _ = writeln!(
727 out,
728 " try XCTSkipIf(true, \"swift: streaming chunk collection is not yet supported via the swift-bridge surface (fixture: {})\")",
729 fixture.id
730 );
731 let _ = writeln!(out, " }}");
732 return;
733 }
734 let collect_snippet = collect_snippet_opt.unwrap_or_default();
735 // The shared streaming snippet references the unqualified `ChatCompletionChunk`
736 // type, but Swift consumers import both `<Module>` (the alef-emitted first-class
737 // `public struct ChatCompletionChunk`) AND `RustBridge` (the swift-bridge
738 // generated `public class ChatCompletionChunk`). Without module qualification
739 // Swift fails the test target with "'ChatCompletionChunk' is ambiguous for
740 // type lookup". Qualify to the first-class type so `chunks` is `[<Module>.ChatCompletionChunk]`.
741 let collect_snippet = if collect_snippet.is_empty() {
742 collect_snippet
743 } else {
744 collect_snippet.replace("[ChatCompletionChunk]", &format!("[{module_name}.ChatCompletionChunk]"))
745 };
746
747 // Detect whether this call has any json_object args that cannot be constructed
748 // in Swift — swift-bridge opaque types do not provide a fromJson initialiser.
749 // When such args exist and no `options_via` is configured for swift, emit a
750 // skip stub so the test compiles but is recorded as skipped rather than
751 // generating invalid code that passes `nil` or a string literal where a
752 // strongly-typed request object is required.
753 let has_unresolvable_json_object_arg = {
754 let options_via = call_overrides.and_then(|o| o.options_via.as_deref());
755 options_via.is_none() && args.iter().any(|a| a.arg_type == "json_object" && a.name != "config")
756 };
757
758 if has_unresolvable_json_object_arg {
759 if is_async {
760 let _ = writeln!(out, " func test{method_name}() async throws {{");
761 } else {
762 let _ = writeln!(out, " func test{method_name}() throws {{");
763 }
764 let _ = writeln!(out, " // {description}");
765 let _ = writeln!(
766 out,
767 " try XCTSkipIf(true, \"swift: json_object request construction requires options_via configuration (fixture: {})\");",
768 fixture.id
769 );
770 let _ = writeln!(out, " }}");
771 return;
772 }
773
774 // Visitor-driven fixtures: emit a class that conforms to `HtmlVisitorProtocol`
775 // and wrap it via `makeHtmlVisitorHandle(...)`. The handle is then threaded
776 // into the options via `conversionOptionsFromJsonWithVisitor(json, handle)`.
777 let mut visitor_setup_lines: Vec<String> = Vec::new();
778 let visitor_handle_expr: Option<String> = fixture
779 .visitor
780 .as_ref()
781 .map(|spec| super::swift_visitors::build_swift_visitor(&mut visitor_setup_lines, spec, &fixture.id));
782
783 // Resolve extra_args from per-call swift overrides (e.g. `nil` for optional
784 // query-param arguments on list_files/list_batches that have no fixture-level
785 // input field).
786 let extra_args: Vec<String> = call_overrides.map(|o| o.extra_args.clone()).unwrap_or_default();
787
788 // Merge per-call enum_fields keys into the effective enum set so that
789 // fields like "status" (BatchStatus, BatchObject) are treated as enum-typed
790 // even when they are not globally listed in fields_enum (they are context-
791 // dependent — BatchStatus on BatchObject but plain String on ResponseObject).
792 let effective_enum_fields: std::borrow::Cow<HashSet<String>> = {
793 let per_call = call_overrides.map(|o| &o.enum_fields);
794 if let Some(pc) = per_call {
795 if !pc.is_empty() {
796 let mut merged = enum_fields.clone();
797 merged.extend(pc.keys().cloned());
798 std::borrow::Cow::Owned(merged)
799 } else {
800 std::borrow::Cow::Borrowed(enum_fields)
801 }
802 } else {
803 std::borrow::Cow::Borrowed(enum_fields)
804 }
805 };
806
807 let options_via_str: Option<&str> = call_overrides.and_then(|o| o.options_via.as_deref());
808 let options_type_str: Option<&str> = call_overrides.and_then(|o| o.options_type.as_deref());
809 // Derive the Swift handle-config parsing function from the C override's
810 // `c_engine_factory` field. E.g. `"CrawlConfig"` → snake → `"crawl_config_from_json"`
811 // → camelCase → `"crawlConfigFromJson"`.
812 let handle_config_fn_owned: Option<String> = call_config
813 .overrides
814 .get("c")
815 .and_then(|c| c.c_engine_factory.as_deref())
816 .map(|ty| format!("{}_from_json", ty.to_snake_case()).to_lower_camel_case());
817 let (mut setup_lines, args_str) = build_args_and_setup(
818 &fixture.input,
819 args,
820 &fixture.id,
821 fixture.has_host_root_route(),
822 &function_name,
823 options_via_str,
824 options_type_str,
825 handle_config_fn_owned.as_deref(),
826 visitor_handle_expr.as_deref(),
827 client_factory.is_some(),
828 module_name,
829 );
830 // Prepend visitor class declarations (before any setup lines that reference the handle).
831 if !visitor_setup_lines.is_empty() {
832 visitor_setup_lines.extend(setup_lines);
833 setup_lines = visitor_setup_lines;
834 }
835
836 // Append extra_args to the argument list.
837 let args_str = if extra_args.is_empty() {
838 args_str
839 } else if args_str.is_empty() {
840 extra_args.join(", ")
841 } else {
842 format!("{args_str}, {}", extra_args.join(", "))
843 };
844
845 // When a client_factory is set, dispatch via a client instance:
846 // let client = try <FactoryType>(apiKey: "test-key", baseUrl: <mock_url>)
847 // try await client.<method>(args)
848 // Otherwise fall back to free-function call (Kreuzberg / non-client-factory libraries).
849 let has_mock = fixture.mock_response.is_some();
850 let (call_setup, call_expr) = if let Some(_factory) = client_factory {
851 let env_key = format!("MOCK_SERVER_{}", fixture.id.to_ascii_uppercase().replace('-', "_"));
852 let mock_url = if fixture.has_host_root_route() {
853 format!(
854 "ProcessInfo.processInfo.environment[\"{env_key}\"] ?? (ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{}\")",
855 fixture.id
856 )
857 } else {
858 format!(
859 "ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{}\"",
860 fixture.id
861 )
862 };
863 let client_constructor = if has_mock {
864 format!("let _client = try DefaultClient(apiKey: \"test-key\", baseUrl: {mock_url})")
865 } else {
866 // Live API: check for api_key_var; if not present use mock URL anyway.
867 if let Some(env_var) = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref()) {
868 format!(
869 "let _apiKey = ProcessInfo.processInfo.environment[\"{env_var}\"]\n \
870 let _baseUrl: String? = _apiKey != nil ? nil : {mock_url}\n \
871 let _client = try DefaultClient(apiKey: _apiKey ?? \"test-key\", baseUrl: _baseUrl)"
872 )
873 } else {
874 format!("let _client = try DefaultClient(apiKey: \"test-key\", baseUrl: {mock_url})")
875 }
876 };
877 let expr = if is_async {
878 format!("try await _client.{function_name}({args_str})")
879 } else {
880 format!("try _client.{function_name}({args_str})")
881 };
882 (Some(client_constructor), expr)
883 } else {
884 // Free-function call (no client_factory).
885 // Qualify with module name to disambiguate between high-level and swift-bridge symbols.
886 let expr = if is_async {
887 format!("try await {module_name}.{function_name}({args_str})")
888 } else {
889 format!("try {module_name}.{function_name}({args_str})")
890 };
891 (None, expr)
892 };
893 // For backwards compatibility: qualified_function_name unused when client_factory is set.
894 let _ = function_name;
895
896 if is_async {
897 let _ = writeln!(out, " func test{method_name}() async throws {{");
898 } else {
899 let _ = writeln!(out, " func test{method_name}() throws {{");
900 }
901 let _ = writeln!(out, " // {description}");
902
903 if expects_error {
904 // For error fixtures, setup may itself throw (e.g. config validation
905 // happens at engine construction). Wrap the whole pipeline — setup
906 // and the call — in a single do/catch so any throw counts as success.
907 if is_async {
908 // XCTAssertThrowsError is a synchronous macro; for async-throwing
909 // functions use a do/catch with explicit XCTFail to enforce that
910 // the throw actually happens. `await XCTAssertThrowsError(...)` is
911 // not valid Swift — it evaluates `await` against a non-async expr.
912 let _ = writeln!(out, " do {{");
913 for line in &setup_lines {
914 let _ = writeln!(out, " {line}");
915 }
916 if let Some(setup) = &call_setup {
917 let _ = writeln!(out, " {setup}");
918 }
919 let _ = writeln!(out, " _ = {call_expr}");
920 let _ = writeln!(out, " XCTFail(\"expected to throw\")");
921 let _ = writeln!(out, " }} catch {{");
922 let _ = writeln!(out, " // success");
923 let _ = writeln!(out, " }}");
924 } else {
925 // Synchronous: emit setup outside (it's expected to succeed) and
926 // wrap only the throwing call in XCTAssertThrowsError. If setup
927 // itself throws, that propagates as the test's own failure — but
928 // sync tests use `throws` so the test method itself rethrows,
929 // which XCTest still records as caught. Keep this simple: use a
930 // do/catch so setup-time throws also count as expected failures.
931 let _ = writeln!(out, " do {{");
932 for line in &setup_lines {
933 let _ = writeln!(out, " {line}");
934 }
935 if let Some(setup) = &call_setup {
936 let _ = writeln!(out, " {setup}");
937 }
938 let _ = writeln!(out, " _ = {call_expr}");
939 let _ = writeln!(out, " XCTFail(\"expected to throw\")");
940 let _ = writeln!(out, " }} catch {{");
941 let _ = writeln!(out, " // success");
942 let _ = writeln!(out, " }}");
943 }
944 let _ = writeln!(out, " }}");
945 return;
946 }
947
948 for line in &setup_lines {
949 let _ = writeln!(out, " {line}");
950 }
951
952 // Emit client construction if a client_factory is configured.
953 if let Some(setup) = &call_setup {
954 let _ = writeln!(out, " {setup}");
955 }
956
957 let _ = writeln!(out, " let {result_var} = {call_expr}");
958
959 // Emit the collect snippet for streaming fixtures (drains the async sequence into
960 // a local `chunks: [ChatCompletionChunk]` array used by streaming-virtual assertions).
961 if !collect_snippet.is_empty() {
962 for line in collect_snippet.lines() {
963 let _ = writeln!(out, " {line}");
964 }
965 }
966
967 // Each fixture's call returns a different IR type. Override the resolver's
968 // Swift first-class-map `root_type` with the call's `result_type` (looked up
969 // across c/csharp/java/kotlin/go/php overrides — these are language-agnostic
970 // IR type names that any backend can use to anchor field-access dispatch).
971 let fixture_root_type: Option<String> = swift_call_result_type(call_config);
972 let fixture_resolver = field_resolver.with_swift_root_type(fixture_root_type);
973
974 for assertion in &fixture.assertions {
975 let mut assertion_out = String::new();
976 render_assertion(
977 &mut assertion_out,
978 assertion,
979 result_var,
980 &fixture_resolver,
981 result_is_simple,
982 result_is_array,
983 result_is_option,
984 result_element_is_string,
985 result_field_accessor,
986 &effective_enum_fields,
987 is_streaming,
988 );
989 // Module-qualify swift-bridge-ambiguous DTO type names that appear in
990 // streaming-virtual assertion expressions (e.g. `[StreamToolCall]`,
991 // `[ToolCall]`). Both `<Module>` (first-class Codable struct) and
992 // `RustBridge` (swift-bridge opaque class) export the same identifier,
993 // so unqualified usage fails Swift compilation with "X is ambiguous for
994 // type lookup". Mirrors the `[ChatCompletionChunk]` replacement in
995 // `render_test_method`.
996 for unqualified in ["StreamToolCall", "ToolCall"] {
997 assertion_out =
998 assertion_out.replace(&format!("[{unqualified}]"), &format!("[{module_name}.{unqualified}]"));
999 }
1000 out.push_str(&assertion_out);
1001 }
1002
1003 let _ = writeln!(out, " }}");
1004}
1005
1006#[allow(clippy::too_many_arguments)]
1007/// Build setup lines and the argument list for the function call.
1008///
1009/// Swift-bridge wrappers require strongly-typed values that don't have implicit
1010/// Swift literal conversions:
1011///
1012/// - `bytes` args become `RustVec<UInt8>` — fixture supplies a relative file path
1013/// string which is read at test time and pushed into a `RustVec<UInt8>` setup
1014/// variable. A literal byte array is base64-decoded or UTF-8 encoded inline.
1015/// - `json_object` args become opaque `ExtractionConfig` (or sibling) instances —
1016/// a JSON string is decoded via `extractionConfigFromJson(...)` in a setup line.
1017/// - Optional args missing from the fixture must still appear at the call site
1018/// as `nil` whenever a later positional arg is present, otherwise Swift slots
1019/// subsequent values into the wrong parameter.
1020fn build_args_and_setup(
1021 input: &serde_json::Value,
1022 args: &[crate::config::ArgMapping],
1023 fixture_id: &str,
1024 has_host_root_route: bool,
1025 function_name: &str,
1026 options_via: Option<&str>,
1027 options_type: Option<&str>,
1028 handle_config_fn: Option<&str>,
1029 visitor_handle_expr: Option<&str>,
1030 is_method_call: bool,
1031 module_name: &str,
1032) -> (Vec<String>, String) {
1033 if args.is_empty() {
1034 return (Vec::new(), String::new());
1035 }
1036
1037 let mut setup_lines: Vec<String> = Vec::new();
1038 let mut parts: Vec<(usize, String)> = Vec::new();
1039
1040 // Pre-compute, for each arg index, whether any later arg has a fixture-provided
1041 // value (or is required and will emit a default). When an optional arg is empty
1042 // but a later arg WILL emit, we must keep the slot with `nil` so positional
1043 // alignment is preserved.
1044 let later_emits: Vec<bool> = (0..args.len())
1045 .map(|i| {
1046 args.iter().skip(i + 1).any(|a| {
1047 let f = a.field.strip_prefix("input.").unwrap_or(&a.field);
1048 let v = input.get(f);
1049 let has_value = matches!(v, Some(x) if !x.is_null());
1050 has_value || !a.optional || (a.arg_type == "json_object" && a.name == "config")
1051 })
1052 })
1053 .collect();
1054
1055 for (idx, arg) in args.iter().enumerate() {
1056 if arg.arg_type == "mock_url" {
1057 let env_key = format!("MOCK_SERVER_{}", fixture_id.to_ascii_uppercase().replace('-', "_"));
1058 let url_expr = if has_host_root_route {
1059 format!(
1060 "ProcessInfo.processInfo.environment[\"{env_key}\"] ?? (ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\")"
1061 )
1062 } else {
1063 format!("ProcessInfo.processInfo.environment[\"MOCK_SERVER_URL\"]! + \"/fixtures/{fixture_id}\"")
1064 };
1065 setup_lines.push(format!("let {} = {url_expr}", arg.name));
1066 parts.push((idx, arg.name.clone()));
1067 continue;
1068 }
1069
1070 if arg.arg_type == "handle" {
1071 let var_name = format!("{}Obj", arg.name.to_lower_camel_case());
1072 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1073 let config_val = input.get(field);
1074 let has_config = config_val
1075 .is_some_and(|v| !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty())));
1076 if has_config {
1077 if let Some(from_json_fn) = handle_config_fn {
1078 let json_str = serde_json::to_string(config_val.unwrap()).unwrap_or_default();
1079 let escaped = escape_swift_str(&json_str);
1080 let config_var = format!("{}Config", arg.name.to_lower_camel_case());
1081 setup_lines.push(format!("let {config_var} = try {from_json_fn}(\"{escaped}\")"));
1082 setup_lines.push(format!("let {var_name} = try createEngine({config_var})"));
1083 } else {
1084 setup_lines.push(format!("let {var_name} = try createEngine(nil)"));
1085 }
1086 } else {
1087 setup_lines.push(format!("let {var_name} = try createEngine(nil)"));
1088 }
1089 parts.push((idx, var_name));
1090 continue;
1091 }
1092
1093 // bytes args: fixture stores a fixture-relative path string. Generate
1094 // setup that reads it into a Data and pushes each byte into a
1095 // RustVec<UInt8>. Literal byte arrays inline the bytes; missing values
1096 // produce an empty vec (or `nil` when optional).
1097 if arg.arg_type == "bytes" {
1098 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1099 let val = input.get(field);
1100 match val {
1101 None | Some(serde_json::Value::Null) if arg.optional => {
1102 if later_emits[idx] {
1103 parts.push((idx, "nil".to_string()));
1104 }
1105 }
1106 None | Some(serde_json::Value::Null) => {
1107 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
1108 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
1109 parts.push((idx, var_name));
1110 }
1111 Some(serde_json::Value::String(s)) => {
1112 let escaped = escape_swift(s);
1113 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
1114 let data_var = format!("{}Data", arg.name.to_lower_camel_case());
1115 setup_lines.push(format!(
1116 "let {data_var} = try Data(contentsOf: URL(fileURLWithPath: \"{escaped}\"))"
1117 ));
1118 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
1119 setup_lines.push(format!("for _byte in {data_var} {{ {var_name}.push(value: _byte) }}"));
1120 parts.push((idx, var_name));
1121 }
1122 Some(serde_json::Value::Array(arr)) => {
1123 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
1124 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
1125 for v in arr {
1126 if let Some(n) = v.as_u64() {
1127 setup_lines.push(format!("{var_name}.push(value: UInt8({n}))"));
1128 }
1129 }
1130 parts.push((idx, var_name));
1131 }
1132 Some(other) => {
1133 // Fallback: encode the JSON serialisation as UTF-8 bytes.
1134 let json_str = serde_json::to_string(other).unwrap_or_default();
1135 let escaped = escape_swift(&json_str);
1136 let var_name = format!("{}Vec", arg.name.to_lower_camel_case());
1137 setup_lines.push(format!("let {var_name} = RustVec<UInt8>()"));
1138 setup_lines.push(format!(
1139 "for _byte in Array(\"{escaped}\".utf8) {{ {var_name}.push(value: _byte) }}"
1140 ));
1141 parts.push((idx, var_name));
1142 }
1143 }
1144 continue;
1145 }
1146
1147 // json_object "config" args: the swift-bridge wrapper requires an opaque
1148 // config instance (e.g., `ExtractionConfig`, `ProcessConfig`), not a JSON string.
1149 // Derive the from-json helper name from options_type if available, else default
1150 // to kreuzberg's `extractionConfigFromJson` for backward compatibility.
1151 // Batch functions (batchExtract*) hardcode config internally — skip it.
1152 let is_config_arg = arg.name == "config" && arg.arg_type == "json_object";
1153 let is_batch_fn = function_name.starts_with("batch") || function_name.starts_with("Batch");
1154 if is_config_arg && !is_batch_fn {
1155 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1156 let val = input.get(field);
1157 let json_str = match val {
1158 None | Some(serde_json::Value::Null) => "{}".to_string(),
1159 Some(v) => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()),
1160 };
1161 let escaped = escape_swift(&json_str);
1162 let var_name = format!("{}Obj", arg.name.to_lower_camel_case());
1163 // Derive the from-json helper name from options_type, or default to extractionConfigFromJson
1164 let from_json_fn = if let Some(type_name) = options_type {
1165 format!("{}FromJson", type_name.to_lower_camel_case())
1166 } else {
1167 "extractionConfigFromJson".to_string()
1168 };
1169 setup_lines.push(format!("let {var_name} = try {from_json_fn}(\"{escaped}\")"));
1170 parts.push((idx, var_name));
1171 continue;
1172 }
1173
1174 // json_object non-config args with options_via = "from_json":
1175 // Use the generated `{typeCamelCase}FromJson(_:)` helper so the fixture JSON is
1176 // deserialised into the opaque swift-bridge type rather than passed as a raw string.
1177 // When arg.field == "input", the entire fixture input IS the request object.
1178 // When a visitor handle is present, use `{typeCamelCase}FromJsonWithVisitor(json, handle)`
1179 // instead to attach the visitor to the options in one step.
1180 if arg.arg_type == "json_object" && options_via == Some("from_json") {
1181 if let Some(type_name) = options_type {
1182 let resolved_val = super::resolve_field(input, &arg.field);
1183 let json_str = match resolved_val {
1184 serde_json::Value::Null => "{}".to_string(),
1185 v => serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string()),
1186 };
1187 let escaped = escape_swift(&json_str);
1188 let var_name = format!("_{}", arg.name.to_lower_camel_case());
1189 if let Some(handle_expr) = visitor_handle_expr {
1190 // Use the visitor-aware helper: `{typeCamelCase}FromJsonWithVisitor(json, handle)`.
1191 // The handle expression builds a VisitorHandle from the local class instance.
1192 // The function name mirrors emit_options_field_options_helper: camelCase of
1193 // `{options_snake}_from_json_with_visitor`.
1194 let with_visitor_fn = format!("{}FromJsonWithVisitor", type_name.to_lower_camel_case());
1195 let handle_var = format!("_visitorHandle_{}", var_name.trim_start_matches('_'));
1196 setup_lines.push(format!("let {handle_var} = {handle_expr}"));
1197 setup_lines.push(format!(
1198 "let {var_name} = try {module_name}.{with_visitor_fn}(\"{escaped}\", {handle_var})"
1199 ));
1200 } else {
1201 let from_json_fn = format!("{}FromJson", type_name.to_lower_camel_case());
1202 setup_lines.push(format!(
1203 "let {var_name} = try {module_name}.{from_json_fn}(\"{escaped}\")"
1204 ));
1205 }
1206 parts.push((idx, var_name));
1207 continue;
1208 }
1209 }
1210
1211 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1212 let val = input.get(field);
1213 match val {
1214 None | Some(serde_json::Value::Null) if arg.optional => {
1215 // Optional arg with no fixture value: keep the slot with `nil`
1216 // when a later arg will emit, so positional alignment matches
1217 // the swift-bridge wrapper signature.
1218 if later_emits[idx] {
1219 parts.push((idx, "nil".to_string()));
1220 }
1221 }
1222 None | Some(serde_json::Value::Null) => {
1223 let default_val = match arg.arg_type.as_str() {
1224 "string" => "\"\"".to_string(),
1225 "int" | "integer" => "0".to_string(),
1226 "float" | "number" => "0.0".to_string(),
1227 "bool" | "boolean" => "false".to_string(),
1228 _ => "nil".to_string(),
1229 };
1230 parts.push((idx, default_val));
1231 }
1232 Some(v) => {
1233 parts.push((idx, json_to_swift(v)));
1234 }
1235 }
1236 }
1237
1238 // Method calls on the DefaultClient handle (e.g. `_client.chat(req)`) use
1239 // anonymous Swift argument labels (`func chat(_ req:)`), so omit `name:` prefixes.
1240 // Free-function calls (e.g. `process(source:, config:)`) keep labelled args.
1241 let args_str = parts
1242 .into_iter()
1243 .map(|(idx, val)| {
1244 if is_method_call {
1245 val
1246 } else {
1247 format!("{}: {}", args[idx].name, val)
1248 }
1249 })
1250 .collect::<Vec<_>>()
1251 .join(", ");
1252 (setup_lines, args_str)
1253}
1254
1255#[allow(clippy::too_many_arguments)]
1256fn render_assertion(
1257 out: &mut String,
1258 assertion: &Assertion,
1259 result_var: &str,
1260 field_resolver: &FieldResolver,
1261 result_is_simple: bool,
1262 result_is_array: bool,
1263 result_is_option: bool,
1264 result_element_is_string: bool,
1265 result_field_accessor: &HashMap<String, String>,
1266 enum_fields: &HashSet<String>,
1267 is_streaming: bool,
1268) {
1269 // When the bare result is `Optional<T>` (no field path) the opaque class
1270 // exposed by swift-bridge has no `.toString()` method, so the usual
1271 // `.toString().isEmpty` pattern produces compile errors. Detect the
1272 // "bare result" case and prefer `XCTAssertNil` / `XCTAssertNotNil`.
1273 let bare_result_is_option = result_is_option && assertion.field.as_deref().filter(|f| !f.is_empty()).is_none();
1274 // Streaming virtual fields resolve against the `chunks` collected-array variable.
1275 // Intercept before is_valid_for_result so they are never skipped.
1276 // Also intercept `usage.*` deep-paths in streaming tests: `AsyncThrowingStream` does
1277 // not have a `usage()` method, so we must route them through the chunks accessor.
1278 if let Some(f) = &assertion.field {
1279 let is_streaming_usage_path =
1280 is_streaming && (f == "usage" || (f.starts_with("usage.") || f.starts_with("usage[")));
1281 // Only route through the streaming-virtual `chunks` accessor when this is
1282 // actually a streaming fixture. Non-streaming fixtures (e.g. `process()`
1283 // with `chunkMaxSize`) expose `chunks` as a real `ProcessResult` field, so
1284 // emit `result.chunks()` via the regular field-accessor path below.
1285 if is_streaming
1286 && !f.is_empty()
1287 && (crate::codegen::streaming_assertions::is_streaming_virtual_field(f) || is_streaming_usage_path)
1288 {
1289 if let Some(expr) =
1290 crate::codegen::streaming_assertions::StreamingFieldResolver::accessor(f, "swift", "chunks")
1291 {
1292 let line = match assertion.assertion_type.as_str() {
1293 "count_min" => {
1294 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1295 format!(" XCTAssertGreaterThanOrEqual(chunks.count, {n})\n")
1296 } else {
1297 String::new()
1298 }
1299 }
1300 "count_equals" => {
1301 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1302 format!(" XCTAssertEqual(chunks.count, {n})\n")
1303 } else {
1304 String::new()
1305 }
1306 }
1307 "equals" => {
1308 if let Some(serde_json::Value::String(s)) = &assertion.value {
1309 let escaped = escape_swift(s);
1310 format!(" XCTAssertEqual({expr}, \"{escaped}\")\n")
1311 } else if let Some(b) = assertion.value.as_ref().and_then(|v| v.as_bool()) {
1312 format!(" XCTAssertEqual({expr}, {b})\n")
1313 } else {
1314 String::new()
1315 }
1316 }
1317 "not_empty" => {
1318 format!(" XCTAssertFalse({expr}.isEmpty, \"expected non-empty\")\n")
1319 }
1320 "is_empty" => {
1321 format!(" XCTAssertTrue({expr}.isEmpty, \"expected empty\")\n")
1322 }
1323 "is_true" => {
1324 format!(" XCTAssertTrue({expr})\n")
1325 }
1326 "is_false" => {
1327 format!(" XCTAssertFalse({expr})\n")
1328 }
1329 "greater_than" => {
1330 if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1331 format!(" XCTAssertGreaterThan(chunks.count, {n})\n")
1332 } else {
1333 String::new()
1334 }
1335 }
1336 "contains" => {
1337 if let Some(serde_json::Value::String(s)) = &assertion.value {
1338 let escaped = escape_swift(s);
1339 format!(
1340 " XCTAssertTrue({expr}.contains(\"{escaped}\"), \"expected to contain: {escaped}\")\n"
1341 )
1342 } else {
1343 String::new()
1344 }
1345 }
1346 _ => format!(
1347 " // streaming field '{f}': assertion type '{}' not rendered\n",
1348 assertion.assertion_type
1349 ),
1350 };
1351 if !line.is_empty() {
1352 out.push_str(&line);
1353 }
1354 }
1355 return;
1356 }
1357 }
1358
1359 // Skip assertions on fields that don't exist on the result type.
1360 if let Some(f) = &assertion.field {
1361 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1362 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
1363 return;
1364 }
1365 }
1366
1367 // Skip assertions that traverse a tagged-union variant boundary.
1368 // In Swift, FormatMetadata and similar enum-backed opaque types are exposed as
1369 // plain classes by swift-bridge — variant accessor methods (e.g., `.excel()`)
1370 // are not generated, so such assertions cannot be expressed.
1371 if let Some(f) = &assertion.field {
1372 if !f.is_empty() && field_resolver.tagged_union_split(f).is_some() {
1373 let _ = writeln!(
1374 out,
1375 " // skipped: field '{f}' crosses a tagged-union variant boundary (not expressible in Swift)"
1376 );
1377 return;
1378 }
1379 }
1380
1381 // Determine if this field is an enum type.
1382 let field_is_enum = assertion
1383 .field
1384 .as_deref()
1385 .is_some_and(|f| enum_fields.contains(f) || enum_fields.contains(field_resolver.resolve(f)));
1386
1387 let field_is_optional = assertion.field.as_deref().is_some_and(|f| {
1388 !f.is_empty() && (field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f)))
1389 });
1390 let field_is_array = assertion.field.as_deref().is_some_and(|f| {
1391 !f.is_empty()
1392 && (field_resolver.is_array(f)
1393 || field_resolver.is_array(field_resolver.resolve(f))
1394 || field_resolver.is_collection_root(f)
1395 || field_resolver.is_collection_root(field_resolver.resolve(f)))
1396 });
1397
1398 let field_expr_raw = if result_is_simple {
1399 result_var.to_string()
1400 } else {
1401 match &assertion.field {
1402 Some(f) if !f.is_empty() => field_resolver.accessor(f, "swift", result_var),
1403 _ => result_var.to_string(),
1404 }
1405 };
1406
1407 // swift-bridge `RustVec<T>` exposes its elements as `T.SelfRef`, which holds
1408 // a raw pointer into the parent Vec's storage. When the Vec is a temporary
1409 // (e.g. `result.json_ld()` called inline), Swift ARC may release it before
1410 // the ref is used, leaving the ref's pointer dangling. Materialise the
1411 // temporary into a local so it survives the full expression chain.
1412 //
1413 // The local name is suffixed with the assertion type plus a hash of the
1414 // assertion's discriminating fields so multiple assertions on the same
1415 // collection don't redeclare the same name.
1416 let local_suffix = {
1417 use std::hash::{Hash, Hasher};
1418 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1419 assertion.field.hash(&mut hasher);
1420 assertion
1421 .value
1422 .as_ref()
1423 .map(|v| v.to_string())
1424 .unwrap_or_default()
1425 .hash(&mut hasher);
1426 format!(
1427 "{}_{:x}",
1428 assertion.assertion_type.replace(['-', '.'], "_"),
1429 hasher.finish() & 0xffff_ffff,
1430 )
1431 };
1432 let (vec_setup, field_expr, is_map_subscript) = materialise_vec_temporaries(&field_expr_raw, &local_suffix);
1433 // The `contains` / `not_contains` traversal branch builds its own
1434 // accessor from `field_resolver.accessor(array_part, ...)`, ignoring
1435 // `field_expr`. Emitting the vec_setup there would produce dead
1436 // `let _vec_… = …` lines, so skip it for those traversal cases.
1437 let field_uses_traversal = assertion.field.as_deref().is_some_and(|f| f.contains("[]."));
1438 let traversal_skips_field_expr = field_uses_traversal
1439 && matches!(
1440 assertion.assertion_type.as_str(),
1441 "contains" | "not_contains" | "not_empty" | "is_empty"
1442 );
1443 if !traversal_skips_field_expr {
1444 for line in &vec_setup {
1445 let _ = writeln!(out, " {line}");
1446 }
1447 }
1448
1449 // In Swift, optional chaining with `?.` makes the result optional even if the
1450 // called method's return type isn't marked optional. For example:
1451 // `result.markdown()?.content()` returns `Optional<RustString>` because
1452 // `markdown()` is optional and the `?.` operator wraps the result.
1453 // Detect this by checking if the accessor contains `?.`.
1454 let accessor_is_optional = field_expr.contains("?.");
1455 // First-class Codable Swift struct property access leaves no trailing `()`
1456 // on the leaf segment — e.g. `result.text` (Swift `String`) vs
1457 // `result.text()` (RustBridge.RustString). When the leaf is property
1458 // access, we already have a Swift `String` (or `String?`) and must NOT
1459 // re-wrap with `.toString()`. Detect this by looking at the final segment
1460 // after the last `.` — property access ends in a bare identifier (no
1461 // trailing `()` or `()?`).
1462 let leaf_is_property_access = {
1463 let trimmed = field_expr.trim_end_matches('?');
1464 // Skip subscripts: `name?[0]` should still see `name` as the field.
1465 let last_segment = trimmed.rsplit_once('.').map(|(_, s)| s).unwrap_or(trimmed);
1466 let last_segment = last_segment.split('[').next().unwrap_or(last_segment);
1467 !last_segment.ends_with(')') && !last_segment.is_empty()
1468 };
1469
1470 // Bare-result Option<T> case: the call returns `Optional<String>` (or
1471 // similar) so the field_expr is `result` typed as `String?`. String
1472 // assertions like `XCTAssertEqual(result.trimmingCharacters(...), …)` will
1473 // not compile against an optional — coalesce to `""` so the macro sees a
1474 // concrete Swift `String`.
1475 let bare_result_is_simple_option =
1476 result_is_simple && result_is_option && assertion.field.as_deref().filter(|f| !f.is_empty()).is_none();
1477
1478 // For enum fields, need to handle the string representation differently in Swift.
1479 // Swift enums don't have `.rawValue` unless they're explicitly RawRepresentable.
1480 // Check if this is an enum type and handle accordingly.
1481 // For optional fields (Optional<RustString>), use optional chaining before toString().
1482 // For other fields: swift-bridge returns all Rust `String` fields as `RustString`.
1483 // We add .toString() here so string assertions (contains, hasPrefix, etc.) work.
1484 // Non-string opaque fields (DocumentStructure, etc.) should not appear in string
1485 // assertions — the fixture schema controls which assertions apply to which fields.
1486 let string_expr = if is_map_subscript {
1487 // The field_expr already evaluates to `String?` (from a JSON-decoded
1488 // `[String: String]` subscript). No `.toString()` chain needed —
1489 // coalesce the optional to "" and use the Swift String directly.
1490 format!("({field_expr} ?? \"\")")
1491 } else if leaf_is_property_access {
1492 // First-class Codable struct field access: leaf is already a Swift
1493 // `String` (or `String?`/enum type) — never a `RustString` requiring
1494 // `.toString()`. For optional leaves, coalesce to "" so XCTAssert
1495 // receives a non-optional Swift `String`.
1496 if field_is_enum && (field_is_optional || accessor_is_optional) {
1497 // Optional first-class Codable enum (e.g. `FinishReason?` where
1498 // `FinishReason: String, Codable`). `.rawValue` gives the serde
1499 // wire value (e.g. "tool_calls") so assertions match fixture JSON.
1500 format!("(({field_expr})?.rawValue ?? \"\")")
1501 } else if field_is_enum {
1502 format!("{field_expr}.rawValue")
1503 } else if field_is_optional || accessor_is_optional || bare_result_is_simple_option {
1504 format!("({field_expr} ?? \"\")")
1505 } else {
1506 field_expr.to_string()
1507 }
1508 } else if field_is_enum && (field_is_optional || accessor_is_optional) {
1509 // Enum-typed fields that are also optional (e.g. `finish_reason() -> Optional<RustString>`)
1510 // must use optional chaining: `?.toString() ?? ""` to unwrap before converting to Swift String.
1511 format!("({field_expr}?.toString() ?? \"\")")
1512 } else if field_is_enum {
1513 // Enum-typed fields are now bridged as `String` (RustString in Swift) rather than
1514 // as opaque enum handles. The getter on the Rust side calls `to_string()` internally
1515 // and returns a `String` across the FFI. In Swift this arrives as `RustString`, so
1516 // `.toString()` converts it to a Swift `String` — one call, not two.
1517 format!("{field_expr}.toString()")
1518 } else if field_is_optional {
1519 // Leaf field itself is Optional<RustString> — need ?.toString() to unwrap.
1520 format!("({field_expr}?.toString() ?? \"\")")
1521 } else if accessor_is_optional {
1522 // Ancestor optional chain propagates; leaf is non-optional RustString within chain.
1523 // Use .toString() directly — the whole expr is Optional<String> due to propagation.
1524 format!("({field_expr}.toString() ?? \"\")")
1525 } else {
1526 format!("{field_expr}.toString()")
1527 };
1528
1529 match assertion.assertion_type.as_str() {
1530 "equals" => {
1531 if let Some(expected) = &assertion.value {
1532 let swift_val = json_to_swift(expected);
1533 if expected.is_string() {
1534 if field_is_enum {
1535 // Enum fields: `to_string()` (snake_case) returns RustString;
1536 // `.toString()` converts it to a Swift String.
1537 // `string_expr` already incorporates this call chain.
1538 let trim_expr =
1539 format!("{string_expr}.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)");
1540 let _ = writeln!(out, " XCTAssertEqual({trim_expr}, {swift_val})");
1541 } else {
1542 // For optional strings (String?), use ?? to coalesce before trimming.
1543 // `.toString()` converts RustString → Swift String before calling
1544 // `.trimmingCharacters`, which requires a concrete String type.
1545 // string_expr already incorporates field_is_optional via ?.toString() ?? "".
1546 let trim_expr =
1547 format!("{string_expr}.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)");
1548 let _ = writeln!(out, " XCTAssertEqual({trim_expr}, {swift_val})");
1549 }
1550 } else {
1551 // For numeric fields, cast the expected value to match the field's type (e.g., UInt).
1552 let cast_swift_val = swift_numeric_literal_cast(&field_expr, &swift_val);
1553 let _ = writeln!(out, " XCTAssertEqual({field_expr}, {cast_swift_val})");
1554 }
1555 }
1556 }
1557 "contains" => {
1558 if let Some(expected) = &assertion.value {
1559 let swift_val = json_to_swift(expected);
1560 // When the root result IS the array (result_is_simple + result_is_array) and
1561 // there is no field path, check array membership via map+contains.
1562 let no_field = assertion.field.as_deref().is_none_or(|f| f.is_empty());
1563 if result_is_simple && result_is_array && no_field {
1564 if result_element_is_string {
1565 // The Swift binding exposes the result as a native
1566 // `[String]` (e.g. `manifestLanguages() -> [String]`),
1567 // not the opaque `RustVec<RustString>`. Iterating
1568 // elements yields plain Swift `String`, which has no
1569 // `asStr()` — emit a direct `.contains(...)` instead.
1570 let _ = writeln!(
1571 out,
1572 " XCTAssertTrue({result_var}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1573 );
1574 } else {
1575 // RustVec<RustString> iteration yields RustStringRef (no `toString()`);
1576 // use `.asStr().toString()` to convert each element to a Swift String.
1577 // swift-bridge renames `as_str` → `asStr` automatically.
1578 let _ = writeln!(
1579 out,
1580 " XCTAssertTrue({result_var}.map {{ $0.asStr().toString() }}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1581 );
1582 }
1583 } else {
1584 // []. traversal: field like "links[].url" → contains(where:) closure.
1585 let traversal_handled = if let Some(f) = assertion.field.as_deref() {
1586 if let Some(dot) = f.find("[].") {
1587 let array_part = &f[..dot];
1588 let elem_part = &f[dot + 3..];
1589 let line = swift_traversal_contains_assert(
1590 array_part,
1591 elem_part,
1592 f,
1593 &swift_val,
1594 result_var,
1595 false,
1596 &format!("expected to contain: \\({swift_val})"),
1597 enum_fields,
1598 field_resolver,
1599 );
1600 let _ = writeln!(out, "{line}");
1601 true
1602 } else {
1603 false
1604 }
1605 } else {
1606 false
1607 };
1608 if !traversal_handled {
1609 // For array fields (RustVec<RustString>), check membership via map+contains.
1610 let field_is_array = assertion
1611 .field
1612 .as_deref()
1613 .is_some_and(|f| field_resolver.is_array(field_resolver.resolve(f)));
1614 if field_is_array {
1615 let (contains_expr, is_optional) = swift_array_contains_expr(
1616 assertion.field.as_deref(),
1617 result_var,
1618 field_resolver,
1619 result_field_accessor,
1620 );
1621 let wrapped = if is_optional {
1622 format!("({contains_expr} ?? [])")
1623 } else {
1624 contains_expr
1625 };
1626 let _ = writeln!(
1627 out,
1628 " XCTAssertTrue({wrapped}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1629 );
1630 } else if field_is_enum {
1631 // Enum fields: use `toString().toString()` (via string_expr) to get the
1632 // serde variant name as a Swift String, then check substring containment.
1633 // Swift's `String.contains("")` returns false; guard with `.isEmpty` so
1634 // fixtures that assert containment of an empty string still pass.
1635 let _ = writeln!(
1636 out,
1637 " XCTAssertTrue({swift_val}.isEmpty || {string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1638 );
1639 } else {
1640 // Same `isEmpty` guard as the enum branch — every string trivially
1641 // "contains" the empty string, but Swift's `String.contains` does not.
1642 let _ = writeln!(
1643 out,
1644 " XCTAssertTrue({swift_val}.isEmpty || {string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1645 );
1646 }
1647 }
1648 }
1649 }
1650 }
1651 "contains_all" => {
1652 if let Some(values) = &assertion.values {
1653 // []. traversal: field like "links[].link_type" → contains(where:) per value.
1654 if let Some(f) = assertion.field.as_deref() {
1655 if let Some(dot) = f.find("[].") {
1656 let array_part = &f[..dot];
1657 let elem_part = &f[dot + 3..];
1658 for val in values {
1659 let swift_val = json_to_swift(val);
1660 let line = swift_traversal_contains_assert(
1661 array_part,
1662 elem_part,
1663 f,
1664 &swift_val,
1665 result_var,
1666 false,
1667 &format!("expected to contain: \\({swift_val})"),
1668 enum_fields,
1669 field_resolver,
1670 );
1671 let _ = writeln!(out, "{line}");
1672 }
1673 // handled — skip remaining branches
1674 } else {
1675 // For array fields (RustVec<RustString>), check membership via map+contains.
1676 let field_is_array = field_resolver.is_array(field_resolver.resolve(f));
1677 if field_is_array {
1678 let (contains_expr, is_optional) = swift_array_contains_expr(
1679 assertion.field.as_deref(),
1680 result_var,
1681 field_resolver,
1682 result_field_accessor,
1683 );
1684 let wrapped = if is_optional {
1685 format!("({contains_expr} ?? [])")
1686 } else {
1687 contains_expr
1688 };
1689 for val in values {
1690 let swift_val = json_to_swift(val);
1691 let _ = writeln!(
1692 out,
1693 " XCTAssertTrue({wrapped}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1694 );
1695 }
1696 } else if field_is_enum {
1697 // Enum fields: use `toString().toString()` (via string_expr) to get the
1698 // serde variant name as a Swift String, then check substring containment.
1699 for val in values {
1700 let swift_val = json_to_swift(val);
1701 let _ = writeln!(
1702 out,
1703 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1704 );
1705 }
1706 } else {
1707 for val in values {
1708 let swift_val = json_to_swift(val);
1709 let _ = writeln!(
1710 out,
1711 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1712 );
1713 }
1714 }
1715 }
1716 } else {
1717 // No field — fall back to existing string_expr path.
1718 for val in values {
1719 let swift_val = json_to_swift(val);
1720 let _ = writeln!(
1721 out,
1722 " XCTAssertTrue({string_expr}.contains({swift_val}), \"expected to contain: \\({swift_val})\")"
1723 );
1724 }
1725 }
1726 }
1727 }
1728 "not_contains" => {
1729 if let Some(expected) = &assertion.value {
1730 let swift_val = json_to_swift(expected);
1731 // []. traversal: "links[].url" → XCTAssertFalse(array.contains(where:))
1732 let traversal_handled = if let Some(f) = assertion.field.as_deref() {
1733 if let Some(dot) = f.find("[].") {
1734 let array_part = &f[..dot];
1735 let elem_part = &f[dot + 3..];
1736 let line = swift_traversal_contains_assert(
1737 array_part,
1738 elem_part,
1739 f,
1740 &swift_val,
1741 result_var,
1742 true,
1743 &format!("expected NOT to contain: \\({swift_val})"),
1744 enum_fields,
1745 field_resolver,
1746 );
1747 let _ = writeln!(out, "{line}");
1748 true
1749 } else {
1750 false
1751 }
1752 } else {
1753 false
1754 };
1755 if !traversal_handled {
1756 let _ = writeln!(
1757 out,
1758 " XCTAssertFalse({string_expr}.contains({swift_val}), \"expected NOT to contain: \\({swift_val})\")"
1759 );
1760 }
1761 }
1762 }
1763 "not_empty" => {
1764 // For optional fields (Optional<T>), check that the value is non-nil.
1765 // For array fields (RustVec<T>), check .isEmpty on the vec directly.
1766 // For result_is_simple (e.g. Data, String), use .isEmpty directly on
1767 // the result — avoids calling .toString() on non-RustString types.
1768 // For string fields, convert to Swift String and check .isEmpty.
1769 // []. traversal: "links[].url" → contains(where: { !elem.isEmpty })
1770 let traversal_not_empty_handled = if let Some(f) = assertion.field.as_deref() {
1771 if let Some(dot) = f.find("[].") {
1772 let array_part = &f[..dot];
1773 let elem_part = &f[dot + 3..];
1774 let array_accessor = field_resolver.accessor(array_part, "swift", result_var);
1775 let resolved_full = field_resolver.resolve(f);
1776 let resolved_elem_part = resolved_full
1777 .find("[].")
1778 .map(|d| &resolved_full[d + 3..])
1779 .unwrap_or(elem_part);
1780 let elem_accessor = field_resolver.accessor(resolved_elem_part, "swift", "$0");
1781 let elem_is_enum = enum_fields.contains(f) || enum_fields.contains(resolved_full);
1782 let elem_is_optional = field_resolver.is_optional(resolved_elem_part)
1783 || field_resolver.is_optional(field_resolver.resolve(resolved_elem_part));
1784 let elem_str = if elem_is_enum {
1785 format!("{elem_accessor}.to_string().toString()")
1786 } else if elem_is_optional {
1787 format!("({elem_accessor}?.toString() ?? \"\")")
1788 } else {
1789 format!("{elem_accessor}.toString()")
1790 };
1791 let _ = writeln!(
1792 out,
1793 " XCTAssertTrue({array_accessor}.contains(where: {{ !{elem_str}.isEmpty }}), \"expected non-empty value\")"
1794 );
1795 true
1796 } else {
1797 false
1798 }
1799 } else {
1800 false
1801 };
1802 if !traversal_not_empty_handled {
1803 if bare_result_is_option {
1804 let _ = writeln!(out, " XCTAssertNotNil({result_var}, \"expected non-nil value\")");
1805 } else if field_is_optional {
1806 let _ = writeln!(out, " XCTAssertNotNil({field_expr}, \"expected non-nil value\")");
1807 } else if field_is_array {
1808 let _ = writeln!(
1809 out,
1810 " XCTAssertFalse({field_expr}.isEmpty, \"expected non-empty value\")"
1811 );
1812 } else if result_is_simple {
1813 // result_is_simple: result is a primitive (Data, String, etc.) — use .isEmpty directly.
1814 let _ = writeln!(
1815 out,
1816 " XCTAssertFalse({result_var}.isEmpty, \"expected non-empty value\")"
1817 );
1818 } else {
1819 // First-class Swift struct fields are properties typed as native Swift
1820 // `String` / `[T]` / `Data` etc — all of which expose `.count` (and
1821 // `String`/`Array` also expose `.isEmpty`). Use `.count > 0` so the same
1822 // path works whether the field is a String or an Array.
1823 //
1824 // When the accessor contains a `?.` optional chain, `.count` returns an
1825 // Optional which Swift cannot compare directly to `0`; coalesce via `?? 0`
1826 // so the assertion typechecks.
1827 //
1828 // For opaque method-call accessors (`result.id()`), the returned type is
1829 // `RustString`, which lacks `.count`. Convert to Swift `String` first via
1830 // `.toString()`. Array fields short-circuit above via `field_is_array`, so
1831 // method-call accessors landing here are guaranteed to be the scalar /
1832 // string flavour; vec accessors return `RustVec` (whose `.count` is fine).
1833 let count_target = swift_count_target(&field_expr, field_resolver, assertion.field.as_deref());
1834 let len_expr = if accessor_is_optional {
1835 format!("({count_target}.count ?? 0)")
1836 } else {
1837 format!("{count_target}.count")
1838 };
1839 let _ = writeln!(
1840 out,
1841 " XCTAssertGreaterThan({len_expr}, 0, \"expected non-empty value\")"
1842 );
1843 }
1844 }
1845 }
1846 "is_empty" => {
1847 if bare_result_is_option {
1848 let _ = writeln!(out, " XCTAssertNil({result_var}, \"expected nil value\")");
1849 } else if field_is_optional {
1850 let _ = writeln!(out, " XCTAssertNil({field_expr}, \"expected nil value\")");
1851 } else if field_is_array {
1852 let _ = writeln!(
1853 out,
1854 " XCTAssertTrue({field_expr}.isEmpty, \"expected empty value\")"
1855 );
1856 } else {
1857 // Symmetric with not_empty: use .count == 0 on first-class Swift types.
1858 // Wrap opaque method-call accessors (`result.id()`) with `.toString()` so
1859 // `.count` lands on Swift `String`, not `RustString` (which lacks `.count`).
1860 let count_target = swift_count_target(&field_expr, field_resolver, assertion.field.as_deref());
1861 let len_expr = if accessor_is_optional {
1862 format!("({count_target}.count ?? 0)")
1863 } else {
1864 format!("{count_target}.count")
1865 };
1866 let _ = writeln!(out, " XCTAssertEqual({len_expr}, 0, \"expected empty value\")");
1867 }
1868 }
1869 "contains_any" => {
1870 if let Some(values) = &assertion.values {
1871 let checks: Vec<String> = values
1872 .iter()
1873 .map(|v| {
1874 let swift_val = json_to_swift(v);
1875 format!("{string_expr}.contains({swift_val})")
1876 })
1877 .collect();
1878 let joined = checks.join(" || ");
1879 let _ = writeln!(
1880 out,
1881 " XCTAssertTrue({joined}, \"expected to contain at least one of the specified values\")"
1882 );
1883 }
1884 }
1885 "greater_than" => {
1886 if let Some(val) = &assertion.value {
1887 let swift_val = json_to_swift(val);
1888 // For optional numeric fields (or when the accessor chain is optional),
1889 // coalesce to 0 before comparing so the expression is non-optional.
1890 let field_is_optional = accessor_is_optional
1891 || assertion.field.as_deref().is_some_and(|f| {
1892 field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f))
1893 });
1894 let compare_expr = if field_is_optional {
1895 let cast_val = swift_numeric_literal_cast(&field_expr, "0");
1896 format!("({field_expr} ?? {cast_val})")
1897 } else {
1898 field_expr.clone()
1899 };
1900 let cast_swift_val = swift_numeric_literal_cast(&field_expr, &swift_val);
1901 let _ = writeln!(out, " XCTAssertGreaterThan({compare_expr}, {cast_swift_val})");
1902 }
1903 }
1904 "less_than" => {
1905 if let Some(val) = &assertion.value {
1906 let swift_val = json_to_swift(val);
1907 let field_is_optional = accessor_is_optional
1908 || assertion.field.as_deref().is_some_and(|f| {
1909 field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f))
1910 });
1911 let compare_expr = if field_is_optional {
1912 let cast_val = swift_numeric_literal_cast(&field_expr, "0");
1913 format!("({field_expr} ?? {cast_val})")
1914 } else {
1915 field_expr.clone()
1916 };
1917 let cast_swift_val = swift_numeric_literal_cast(&field_expr, &swift_val);
1918 let _ = writeln!(out, " XCTAssertLessThan({compare_expr}, {cast_swift_val})");
1919 }
1920 }
1921 "greater_than_or_equal" => {
1922 if let Some(val) = &assertion.value {
1923 let swift_val = json_to_swift(val);
1924 // For optional numeric fields (or when the accessor chain is optional),
1925 // coalesce to 0 before comparing so the expression is non-optional.
1926 let field_is_optional = accessor_is_optional
1927 || assertion.field.as_deref().is_some_and(|f| {
1928 field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f))
1929 });
1930 let compare_expr = if field_is_optional {
1931 let cast_val = swift_numeric_literal_cast(&field_expr, "0");
1932 format!("({field_expr} ?? {cast_val})")
1933 } else {
1934 field_expr.clone()
1935 };
1936 let cast_swift_val = swift_numeric_literal_cast(&field_expr, &swift_val);
1937 let _ = writeln!(
1938 out,
1939 " XCTAssertGreaterThanOrEqual({compare_expr}, {cast_swift_val})"
1940 );
1941 }
1942 }
1943 "less_than_or_equal" => {
1944 if let Some(val) = &assertion.value {
1945 let swift_val = json_to_swift(val);
1946 let field_is_optional = accessor_is_optional
1947 || assertion.field.as_deref().is_some_and(|f| {
1948 field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f))
1949 });
1950 let compare_expr = if field_is_optional {
1951 let cast_val = swift_numeric_literal_cast(&field_expr, "0");
1952 format!("({field_expr} ?? {cast_val})")
1953 } else {
1954 field_expr.clone()
1955 };
1956 let cast_swift_val = swift_numeric_literal_cast(&field_expr, &swift_val);
1957 let _ = writeln!(
1958 out,
1959 " XCTAssertLessThanOrEqual({compare_expr}, {cast_swift_val})"
1960 );
1961 }
1962 }
1963 "starts_with" => {
1964 if let Some(expected) = &assertion.value {
1965 let swift_val = json_to_swift(expected);
1966 let _ = writeln!(
1967 out,
1968 " XCTAssertTrue({string_expr}.hasPrefix({swift_val}), \"expected to start with: \\({swift_val})\")"
1969 );
1970 }
1971 }
1972 "ends_with" => {
1973 if let Some(expected) = &assertion.value {
1974 let swift_val = json_to_swift(expected);
1975 let _ = writeln!(
1976 out,
1977 " XCTAssertTrue({string_expr}.hasSuffix({swift_val}), \"expected to end with: \\({swift_val})\")"
1978 );
1979 }
1980 }
1981 "min_length" => {
1982 if let Some(val) = &assertion.value {
1983 if let Some(n) = val.as_u64() {
1984 // Use string_expr.count: for RustString fields string_expr already has
1985 // .toString() appended, giving a Swift String whose .count is character count.
1986 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({string_expr}.count, {n})");
1987 }
1988 }
1989 }
1990 "max_length" => {
1991 if let Some(val) = &assertion.value {
1992 if let Some(n) = val.as_u64() {
1993 let _ = writeln!(out, " XCTAssertLessThanOrEqual({string_expr}.count, {n})");
1994 }
1995 }
1996 }
1997 "count_min" => {
1998 if let Some(val) = &assertion.value {
1999 if let Some(n) = val.as_u64() {
2000 // For fields nested inside an optional parent (e.g. document.nodes where
2001 // document is Optional), the accessor generates `result.document().nodes()`
2002 // which doesn't compile in Swift without optional chaining.
2003 let count_expr = swift_array_count_expr(assertion.field.as_deref(), result_var, field_resolver);
2004 let _ = writeln!(out, " XCTAssertGreaterThanOrEqual({count_expr}, {n})");
2005 }
2006 }
2007 }
2008 "count_equals" => {
2009 if let Some(val) = &assertion.value {
2010 if let Some(n) = val.as_u64() {
2011 let count_expr = swift_array_count_expr(assertion.field.as_deref(), result_var, field_resolver);
2012 let _ = writeln!(out, " XCTAssertEqual({count_expr}, {n})");
2013 }
2014 }
2015 }
2016 "is_true" => {
2017 let _ = writeln!(out, " XCTAssertTrue({field_expr})");
2018 }
2019 "is_false" => {
2020 let _ = writeln!(out, " XCTAssertFalse({field_expr})");
2021 }
2022 "matches_regex" => {
2023 if let Some(expected) = &assertion.value {
2024 let swift_val = json_to_swift(expected);
2025 let _ = writeln!(
2026 out,
2027 " XCTAssertNotNil({string_expr}.range(of: {swift_val}, options: .regularExpression), \"expected value to match regex: \\({swift_val})\")"
2028 );
2029 }
2030 }
2031 "not_error" => {
2032 // Already handled by the call succeeding without exception.
2033 }
2034 "error" => {
2035 // Handled at the test method level.
2036 }
2037 "method_result" => {
2038 let _ = writeln!(out, " // method_result assertions not yet implemented for Swift");
2039 }
2040 other => {
2041 panic!("Swift e2e generator: unsupported assertion type: {other}");
2042 }
2043 }
2044}
2045
2046/// Build a Swift accessor path for the given fixture field, inserting `()` on
2047/// every segment and `?` after every optional non-leaf segment.
2048///
2049/// This is the core helper for count/contains helpers that need to reconstruct
2050/// the path with correct optional chaining from the raw fixture field name.
2051///
2052/// Rewrite a Swift accessor expression to capture any `RustVec` temporaries
2053/// in a local before subscripting them. Returns `(setup_lines, rewritten_expr)`.
2054///
2055/// swift-bridge's `Vec_<T>$get` returns a raw pointer into the Vec's storage
2056/// wrapped in a `T.SelfRef`. If the Vec was a temporary, ARC may release it
2057/// before the ref is dereferenced, leaving the pointer dangling and reads
2058/// returning empty/garbage. Hoisting the Vec into a `let` binding ties the
2059/// Vec's lifetime to the enclosing function scope, so the ref stays valid.
2060///
2061/// Only the first `()[...]` occurrence per expression is materialised — that
2062/// covers all current fixture access patterns (single-level subscripts on a
2063/// result field). Nested subscripts are rare and would need a more elaborate
2064/// pass; if they appear, this returns conservative output (just the first
2065/// hoist) which is still correct.
2066/// Returns `(setup_lines, rewritten_expr, is_map_subscript)`. `is_map_subscript` is
2067/// true when the subscript key was a string literal, indicating the parent
2068/// accessor returns a JSON-encoded Map (RustString) and the rewritten expression
2069/// already evaluates to `String?` so callers should NOT append `.toString()`.
2070fn materialise_vec_temporaries(expr: &str, name_suffix: &str) -> (Vec<String>, String, bool) {
2071 let Some(idx) = expr.find("()[") else {
2072 return (Vec::new(), expr.to_string(), false);
2073 };
2074 let after_open = idx + 3; // position after `()[`
2075 let Some(close_rel) = expr[after_open..].find(']') else {
2076 return (Vec::new(), expr.to_string(), false);
2077 };
2078 let subscript_end = after_open + close_rel; // index of `]`
2079 let prefix = &expr[..idx + 2]; // includes `()`
2080 let subscript = &expr[idx + 2..=subscript_end]; // `[N]`
2081 let tail = &expr[subscript_end + 1..]; // everything after `]`
2082 let method_dot = expr[..idx].rfind('.').unwrap_or(0);
2083 let method = &expr[method_dot + 1..idx];
2084 let local = format!("_vec_{}_{}", method, name_suffix);
2085
2086 // String-key subscript (e.g. `["title"]`) signals a Map-like access. swift-bridge
2087 // serialises non-leaf Maps (e.g. `HashMap<String, String>`) as JSON-encoded
2088 // RustString rather than exposing a Swift dictionary. Decode the RustString to
2089 // `[String: String]` before subscripting so `_vec_X["title"]` works.
2090 let inner = subscript.trim_start_matches('[').trim_end_matches(']');
2091 let is_string_key = inner.starts_with('"') && inner.ends_with('"');
2092 let setup = if is_string_key {
2093 format!(
2094 "let {local} = (try? JSONSerialization.jsonObject(with: ({prefix}.toString() ?? \"{{}}\").data(using: .utf8)!) as? [String: String]) ?? [:]"
2095 )
2096 } else {
2097 format!("let {local} = {prefix}")
2098 };
2099
2100 let rewritten = format!("{local}{subscript}{tail}");
2101 (vec![setup], rewritten, is_string_key)
2102}
2103
2104/// Returns `(accessor_expr, has_optional)` where `has_optional` is true when
2105/// at least one `?.` was inserted.
2106fn swift_build_accessor(field: &str, result_var: &str, field_resolver: &FieldResolver) -> (String, bool) {
2107 let resolved = field_resolver.resolve(field);
2108 let parts: Vec<&str> = resolved.split('.').collect();
2109
2110 // Track the current IR type as we walk segments so each segment can be
2111 // emitted with property syntax (first-class Codable struct) or method-call
2112 // syntax (typealias-to-`RustBridge.X`). Mirrors the per-segment dispatch in
2113 // `render_swift_with_first_class_map`.
2114 let mut current_type: Option<String> = field_resolver.swift_root_type().cloned();
2115 // Once a chain crosses a `[N]` subscript, we are operating on a RustVec
2116 // element, which is always the OPAQUE `RustBridge.T` (swift-bridge does not
2117 // convert RustVec elements into the first-class Codable struct). Pin
2118 // opaque method-call syntax after the first index step.
2119 let mut via_rust_vec = false;
2120 // Once a chain crosses an opaque (typealias-to-`RustBridge.X`) segment, every
2121 // subsequent accessor must also be opaque (method-call syntax). Calling a
2122 // method on `RustBridge.X` returns the OPAQUE wrapper of the next type, even
2123 // when that next type is independently eligible for first-class emission.
2124 // See `field_access::render_swift_with_first_class_map` for the matching
2125 // invariant. Without this, `metrics.total_lines` on an opaque parent emits
2126 // `.metrics().totalLines` instead of `.metrics().totalLines()`.
2127 let mut via_opaque = false;
2128
2129 let mut out = result_var.to_string();
2130 let mut has_optional = false;
2131 let mut path_so_far = String::new();
2132 let total = parts.len();
2133 for (i, part) in parts.iter().enumerate() {
2134 let is_leaf = i == total - 1;
2135 // Handle array index subscripts within a segment, e.g. `data[0]`.
2136 // `data[0]` must become `.data()[0]` (opaque) or `.data[0]` (first-class).
2137 // Split at the first `[` if present.
2138 let (field_name, subscript): (&str, Option<&str>) = if let Some(bracket_pos) = part.find('[') {
2139 (&part[..bracket_pos], Some(&part[bracket_pos..]))
2140 } else {
2141 (part, None)
2142 };
2143
2144 if !path_so_far.is_empty() {
2145 path_so_far.push('.');
2146 }
2147 // Build the base path (without subscript) for the optional check. When the
2148 // segment is e.g. `tool_calls[0]`, we want to check `is_optional` against
2149 // "choices[0].message.tool_calls" not "choices[0].message.tool_calls[0]".
2150 let base_path = {
2151 let mut p = path_so_far.clone();
2152 p.push_str(field_name);
2153 p
2154 };
2155 // Now push the full part (with subscript if any) so path_so_far is correct
2156 // for subsequent segment checks.
2157 path_so_far.push_str(part);
2158
2159 // First-class struct fields → property access (no `()`); typealias-to-
2160 // opaque fields → method-call access (`()`). Once we've indexed through
2161 // a RustVec, every subsequent segment is on an opaque element.
2162 // When current_type is None (opaque parent that doesn't appear in field_types),
2163 // treat it as opaque and use method-call syntax.
2164 let is_first_class = current_type
2165 .as_ref()
2166 .is_some_and(|t| field_resolver.swift_is_first_class(Some(t)));
2167 let property_syntax = !via_rust_vec && !via_opaque && is_first_class;
2168 if !property_syntax {
2169 via_opaque = true;
2170 }
2171 out.push('.');
2172 // Swift bindings (both first-class `public let` props and swift-bridge
2173 // method names) always use lowerCamelCase — never raw snake_case from IR.
2174 out.push_str(&field_name.to_lower_camel_case());
2175 if let Some(sub) = subscript {
2176 // When the getter for this subscripted field is itself optional
2177 // (e.g. tool_calls returns Optional<RustVec<T>>), insert `?` before
2178 // the subscript so Swift unwraps the Optional before indexing.
2179 let field_is_optional = field_resolver.is_optional(&base_path);
2180 let access = if property_syntax { "" } else { "()" };
2181 if field_is_optional {
2182 out.push_str(&format!("{access}?"));
2183 has_optional = true;
2184 } else {
2185 out.push_str(access);
2186 }
2187 out.push_str(sub);
2188 // Do NOT append a trailing `?` after the subscript index: in Swift,
2189 // `optionalVec?[N]` via `Collection.subscript` returns the element
2190 // type `T` directly. The parent `has_optional` flag is still set
2191 // when `field_is_optional` is true, which causes the enclosing
2192 // expression to be wrapped in `(... ?? fallback)` correctly.
2193 // Indexing into a Vec<Named> yields a Named element. Only pin opaque
2194 // syntax when the array itself was opaque (method-call); when the
2195 // owner is first-class, the array is a Swift `[T]` whose elements
2196 // are first-class T (property access).
2197 current_type = field_resolver.swift_advance(current_type.as_deref(), field_name);
2198 if !property_syntax {
2199 via_rust_vec = true;
2200 }
2201 } else {
2202 if !property_syntax {
2203 out.push_str("()");
2204 }
2205 // Insert `?` after the accessor for non-leaf optional fields so the
2206 // next member access becomes `?.`.
2207 if !is_leaf && field_resolver.is_optional(&base_path) {
2208 out.push('?');
2209 has_optional = true;
2210 }
2211 current_type = field_resolver.swift_advance(current_type.as_deref(), field_name);
2212 }
2213 }
2214 (out, has_optional)
2215}
2216
2217/// Generate a `[String]` (or `[String]?`) expression for a `RustVec<RustString>`
2218/// field so that `contains` membership checks work against plain Swift Strings.
2219///
2220/// We use `.map { $0.asStr().toString() }` because:
2221/// 1. Iterating a `RustVec<RustString>` yields `RustStringRef` (not `RustString`), which
2222/// only has `asStr()` but not `toString()` directly. swift-bridge auto-renames the
2223/// Rust `as_str` method to lowerCamelCase `asStr` on the Swift side.
2224/// 2. The accessor may end with an `Optional<RustVec<RustString>>` (e.g. `sheet_names()` is
2225/// `Option<Vec<String>>` in Rust, which becomes `Optional<RustVec<RustString>>` in Swift).
2226/// 3. Optional chaining from parent `?.` already produces `Optional<RustVec<T>>`.
2227///
2228/// The returned tuple's bool indicates whether the result is `Optional<[String]>`
2229/// (callers coalesce with `?? []`) or already a concrete `[String]`. Emitting
2230/// `?? []` against a non-optional value compiles with a Swift warning but is
2231/// surfaced as an error in strict CI configurations, so we only emit `?.map`
2232/// + `?? []` when the accessor is genuinely optional.
2233///
2234/// Generate a `XCTAssert{True|False}(array.contains(where: { elem_str.contains(val) }), msg)` line
2235/// for field paths that traverse a collection with `[].` notation (e.g. `links[].url`).
2236///
2237/// `array_part` — left side of `[].` (e.g. `"links"`)
2238/// `element_part` — right side (e.g. `"url"` or `"link_type"`)
2239/// `full_field` — original assertion.field (used for enum lookup against the full path)
2240#[allow(clippy::too_many_arguments)]
2241fn swift_traversal_contains_assert(
2242 array_part: &str,
2243 element_part: &str,
2244 full_field: &str,
2245 val_expr: &str,
2246 result_var: &str,
2247 negate: bool,
2248 msg: &str,
2249 enum_fields: &std::collections::HashSet<String>,
2250 field_resolver: &FieldResolver,
2251) -> String {
2252 let array_accessor = field_resolver.accessor(array_part, "swift", result_var);
2253 let resolved_full = field_resolver.resolve(full_field);
2254 let resolved_elem_part = resolved_full
2255 .find("[].")
2256 .map(|d| &resolved_full[d + 3..])
2257 .unwrap_or(element_part);
2258 let elem_accessor = field_resolver.accessor(resolved_elem_part, "swift", "$0");
2259 let elem_is_enum = enum_fields.contains(full_field) || enum_fields.contains(resolved_full);
2260 let elem_is_optional = field_resolver.is_optional(resolved_elem_part)
2261 || field_resolver.is_optional(field_resolver.resolve(resolved_elem_part));
2262 let elem_str = if elem_is_enum {
2263 // Enum-typed fields are bridged as `String` (RustString in Swift).
2264 // A single `.toString()` converts RustString → Swift String.
2265 format!("{elem_accessor}.toString()")
2266 } else if elem_is_optional {
2267 format!("({elem_accessor}?.toString() ?? \"\")")
2268 } else {
2269 format!("{elem_accessor}.toString()")
2270 };
2271 let assert_fn = if negate { "XCTAssertFalse" } else { "XCTAssertTrue" };
2272 format!(" {assert_fn}({array_accessor}.contains(where: {{ {elem_str}.contains({val_expr}) }}), \"{msg}\")")
2273}
2274
2275/// Returns `(map_expr, is_optional)` where `map_expr` is the `.map { … }` chain
2276/// that converts each element to a Swift `String`, and `is_optional` reports
2277/// whether the resulting expression is `Optional<[String]>` (callers should
2278/// coalesce with `?? []`) or already a concrete `[String]`.
2279fn swift_array_contains_expr(
2280 field: Option<&str>,
2281 result_var: &str,
2282 field_resolver: &FieldResolver,
2283 result_field_accessor: &HashMap<String, String>,
2284) -> (String, bool) {
2285 // swift-bridge auto-renames Rust snake_case methods to lowerCamelCase on the
2286 // Swift side. `RustStringRef::as_str()` is exposed as `asStr()` — emitting
2287 // `as_str()` produces "value of type 'XRef' has no member 'as_str'" at
2288 // compile time.
2289 let Some(f) = field else {
2290 return (format!("{result_var}.map {{ $0.asStr().toString() }}"), false);
2291 };
2292 // Allow per-call overrides to name a different element accessor — used when
2293 // the array element is an opaque struct whose "name string" accessor is
2294 // not `as_str` (e.g. `StructureItem` exposes `kind() -> String`). The map
2295 // is keyed on the fixture field name (and resolved alias as a fallback).
2296 let resolved_field = field_resolver.resolve(f);
2297 let elem_accessor_name = result_field_accessor
2298 .get(f)
2299 .or_else(|| result_field_accessor.get(resolved_field))
2300 .cloned()
2301 .unwrap_or_else(|| "as_str".to_string());
2302 let elem_call = swift_ident(&elem_accessor_name.to_lower_camel_case());
2303 let (accessor, has_optional) = swift_build_accessor(f, result_var, field_resolver);
2304 // Only chain `?.map` when the accessor is actually optional. The previous
2305 // unconditional `?.map` produced "cannot use optional chaining on
2306 // non-optional value of type 'RustVec<…>'" for plain `Vec<T>` fields.
2307 let field_is_optional =
2308 has_optional || field_resolver.is_optional(f) || field_resolver.is_optional(field_resolver.resolve(f));
2309 if field_is_optional {
2310 (format!("{accessor}?.map {{ $0.{elem_call}().toString() }}"), true)
2311 } else {
2312 (format!("{accessor}.map {{ $0.{elem_call}().toString() }}"), false)
2313 }
2314}
2315
2316/// Generate a `.count` expression for an array field that may be nested inside optional parents.
2317///
2318/// Swift-bridge exposes all Rust fields as methods with `()`. When ancestor segments are
2319/// optional, we use `?.` chaining. The final count is coalesced with `?? 0` when there
2320/// are optional ancestors so the XCTAssert macro receives a non-optional `Int`.
2321///
2322/// Also check if the field itself (the leaf) is optional, which happens when the field
2323/// returns Optional<RustVec<T>> (e.g., `links()` may return Optional).
2324fn swift_array_count_expr(field: Option<&str>, result_var: &str, field_resolver: &FieldResolver) -> String {
2325 let Some(f) = field else {
2326 return format!("{result_var}.count");
2327 };
2328 let (accessor, mut has_optional) = swift_build_accessor(f, result_var, field_resolver);
2329 // Also check if the leaf field itself is optional.
2330 if field_resolver.is_optional(f) {
2331 has_optional = true;
2332 }
2333 if has_optional {
2334 // In Swift, accessing .count on an optional with ?. returns Optional<Int>,
2335 // so we coalesce with ?? 0 to get a concrete Int for XCTAssert.
2336 if accessor.contains("?.") {
2337 format!("{accessor}.count ?? 0")
2338 } else {
2339 // If no ?. but field is optional, the field_expr itself is Optional<RustVec<T>>
2340 // so we need ?. to call count.
2341 format!("({accessor}?.count ?? 0)")
2342 }
2343 } else {
2344 format!("{accessor}.count")
2345 }
2346}
2347
2348/// Convert a `serde_json::Value` to a Swift literal string.
2349fn json_to_swift(value: &serde_json::Value) -> String {
2350 match value {
2351 serde_json::Value::String(s) => format!("\"{}\"", escape_swift(s)),
2352 serde_json::Value::Bool(b) => b.to_string(),
2353 serde_json::Value::Number(n) => n.to_string(),
2354 serde_json::Value::Null => "nil".to_string(),
2355 serde_json::Value::Array(arr) => {
2356 let items: Vec<String> = arr.iter().map(json_to_swift).collect();
2357 format!("[{}]", items.join(", "))
2358 }
2359 serde_json::Value::Object(_) => {
2360 let json_str = serde_json::to_string(value).unwrap_or_default();
2361 format!("\"{}\"", escape_swift(&json_str))
2362 }
2363 }
2364}
2365
2366/// When comparing numeric values in Swift, if the field expression uses method-call
2367/// syntax (contains `()` indicating opaque swift-bridge types), we need to consider
2368/// the numeric literal type. Integer literals may need wrapping in `UInt(...)` to
2369/// match opaque methods that return UInt (like `metrics().totalLines()`). However,
2370/// floating-point literals should NOT be wrapped, as they may compare against fields
2371/// that return `Double` (like `relevanceScore()`). Type inference should handle the
2372/// field type correctly based on the comparison operator and literal value.
2373fn swift_numeric_literal_cast(field_expr: &str, numeric_literal: &str) -> String {
2374 // Only wrap integer literals in UInt(...) for method-call expressions.
2375 // Don't wrap floats, as the field type is unknown and may return Double.
2376 if field_expr.contains("()") && !numeric_literal.contains('.') {
2377 format!("UInt({})", numeric_literal)
2378 } else {
2379 numeric_literal.to_string()
2380 }
2381}
2382
2383/// Escape a string for embedding in a Swift double-quoted string literal.
2384fn escape_swift(s: &str) -> String {
2385 escape_swift_str(s)
2386}
2387
2388/// Return the count-able target expression for `field_expr`.
2389///
2390/// For opaque method-call accessors (ending in `()` or `()?`), the returned
2391/// value depends on the field's IR kind:
2392///
2393/// - `Vec<T>` ⇒ `RustVec<T>`, which exposes `.count` directly. No wrap.
2394/// - `String` ⇒ `RustString`, which does NOT expose `.count`. Wrap with
2395/// `.toString()` so `.count` lands on Swift `String`.
2396///
2397/// First-class property accessors (no trailing parens) return Swift values
2398/// that already support `.count` directly.
2399///
2400/// The discriminator is the field's resolved leaf type, looked up against the
2401/// `SwiftFirstClassMap`'s vec field set when available. If the field is
2402/// unknown (None), fall back to the conservative wrap — RustString is the
2403/// dominant scalar-leaf case for top-level assertions.
2404fn swift_count_target(field_expr: &str, field_resolver: &FieldResolver, field: Option<&str>) -> String {
2405 let is_method_call = field_expr.trim_end().ends_with(')');
2406 if !is_method_call {
2407 return field_expr.to_string();
2408 }
2409 if let Some(f) = field
2410 && field_resolver.leaf_is_vec_via_swift_map(field_resolver.resolve(f))
2411 {
2412 return field_expr.to_string();
2413 }
2414 format!("{field_expr}.toString()")
2415}
2416
2417/// Resolve the IR type name backing this call's result.
2418///
2419/// Lookup order mirrors PHP's `derive_root_type` for `[crates.e2e.calls.*]`
2420/// configs: any of `c, csharp, java, kotlin, go, php` overrides may carry a
2421/// `result_type = "ChatCompletionResponse"` field. The first non-empty value
2422/// wins. These overrides are language-agnostic IR type names — they were
2423/// originally added for the C/C# backends and other backends piggy-back on them
2424/// because the IR names are shared across every binding.
2425///
2426/// Returns `None` when no override sets `result_type`; the renderer then falls
2427/// back to the workspace-default heuristic in `SwiftFirstClassMap` (which
2428/// defaults to property access — the right call for first-class result types
2429/// like `FileObject` but wrong for opaque types like `ChatCompletionResponse`).
2430fn swift_call_result_type(call_config: &alef_core::config::e2e::CallConfig) -> Option<String> {
2431 const LOOKUP_LANGS: &[&str] = &["c", "csharp", "java", "kotlin", "go", "php"];
2432 for lang in LOOKUP_LANGS {
2433 if let Some(o) = call_config.overrides.get(*lang)
2434 && let Some(rt) = o.result_type.as_deref()
2435 && !rt.is_empty()
2436 {
2437 return Some(rt.to_string());
2438 }
2439 }
2440 None
2441}
2442
2443/// Returns true when the field type would be emitted as a Swift primitive value
2444/// or a known first-class Codable struct/unit-enum, so it can appear on a
2445/// first-class Codable Swift struct without forcing the host type into a
2446/// typealias. Mirrors `first_class_field_supported` in alef-backend-swift.
2447///
2448/// Accepts:
2449/// - `Primitive` and `String`
2450/// - `Named(S)` when `S` is in `known_dto_names` (seeded with unit-serde enums and
2451/// grown via fixed-point iteration over candidate struct DTOs)
2452/// - `Vec<T>` and `Optional<T>` recursively
2453///
2454/// Rejects `Map`, `Path`, `Bytes`, `Duration`, `Char`, `Json`, and unknown
2455/// `Named(_)` references (the backend treats those as typealias-to-opaque).
2456fn swift_first_class_field_supported(ty: &alef_core::ir::TypeRef, known_dto_names: &HashSet<String>) -> bool {
2457 use alef_core::ir::TypeRef;
2458 match ty {
2459 TypeRef::Primitive(_) | TypeRef::String => true,
2460 TypeRef::Named(name) => known_dto_names.contains(name),
2461 TypeRef::Vec(inner) | TypeRef::Optional(inner) => swift_first_class_field_supported(inner, known_dto_names),
2462 _ => false,
2463 }
2464}
2465
2466/// Build the per-type Swift first-class/opaque classification map used by
2467/// `render_swift_with_first_class_map`.
2468///
2469/// A TypeDef is treated as first-class (Codable Swift struct → property access)
2470/// when it is not opaque, has serde derives, has at least one field, and every
2471/// binding field is supported by `swift_first_class_field_supported` against the
2472/// current first-class set. All other public types end up as typealiases to
2473/// opaque `RustBridge.X` classes whose fields are swift-bridge methods
2474/// (`.id()`, `.status()`).
2475///
2476/// Mirrors the fixed-point iteration in `alef-backend-swift::gen_bindings.rs`
2477/// (lines 100-130). Without the fixed point, a type like `TranscriptionResponse`
2478/// that holds `Option<Vec<TranscriptionSegment>>` would be wrongly classified
2479/// opaque, causing the renderer to emit `.text()` against a first-class struct
2480/// whose `text` is a `public let` property.
2481///
2482/// `field_types` records the next-type that each Named field traverses into,
2483/// so the renderer can advance its current-type cursor through nested
2484/// `data[0].id` style paths.
2485fn build_swift_first_class_map(
2486 type_defs: &[alef_core::ir::TypeDef],
2487 enum_defs: &[alef_core::ir::EnumDef],
2488 e2e_config: &crate::config::E2eConfig,
2489) -> SwiftFirstClassMap {
2490 use alef_core::ir::TypeRef;
2491 let mut field_types: HashMap<String, HashMap<String, String>> = HashMap::new();
2492 let mut vec_field_names: HashSet<String> = HashSet::new();
2493 fn inner_named(ty: &TypeRef) -> Option<String> {
2494 match ty {
2495 TypeRef::Named(n) => Some(n.clone()),
2496 TypeRef::Optional(inner) | TypeRef::Vec(inner) => inner_named(inner),
2497 _ => None,
2498 }
2499 }
2500 fn is_vec_ty(ty: &TypeRef) -> bool {
2501 match ty {
2502 TypeRef::Vec(_) => true,
2503 TypeRef::Optional(inner) => is_vec_ty(inner),
2504 _ => false,
2505 }
2506 }
2507 // Seed with unit serde enum names — Codable on the Swift side and can appear
2508 // as leaf fields on struct DTOs (matches gen_bindings.rs unit_serde_enum_names).
2509 let mut known_dto_names: HashSet<String> = enum_defs
2510 .iter()
2511 .filter(|e| e.has_serde && e.variants.iter().all(|v| v.fields.is_empty()))
2512 .map(|e| e.name.clone())
2513 .collect();
2514
2515 // Candidate struct DTOs: non-opaque, has_serde, non-empty fields.
2516 // Trait types and binding-excluded types are skipped (matches backend semantics
2517 // — note backend further filters via `exclude_types`, which we don't have here,
2518 // but accepting a superset is safe: types not actually emitted simply never
2519 // appear in path-access chains).
2520 let candidates: Vec<&alef_core::ir::TypeDef> = type_defs
2521 .iter()
2522 .filter(|td| !td.is_trait && !td.is_opaque && td.has_serde && !td.fields.is_empty())
2523 .collect();
2524
2525 loop {
2526 let prev = known_dto_names.len();
2527 for td in &candidates {
2528 if known_dto_names.contains(&td.name) {
2529 continue;
2530 }
2531 let all_supported = td
2532 .fields
2533 .iter()
2534 .filter(|f| !f.binding_excluded)
2535 .all(|f| swift_first_class_field_supported(&f.ty, &known_dto_names));
2536 if all_supported {
2537 known_dto_names.insert(td.name.clone());
2538 }
2539 }
2540 if known_dto_names.len() == prev {
2541 break;
2542 }
2543 }
2544
2545 // The first-class set on SwiftFirstClassMap conceptually represents structs
2546 // accessed via property syntax. Unit enums never appear as the *owner* of a
2547 // chain segment (they are leaves), but including them is harmless since
2548 // `advance()` never returns them as a current_type for further traversal.
2549 let first_class_types: HashSet<String> = candidates
2550 .iter()
2551 .filter(|td| known_dto_names.contains(&td.name))
2552 .map(|td| td.name.clone())
2553 .collect();
2554
2555 for td in type_defs {
2556 let mut td_field_types: HashMap<String, String> = HashMap::new();
2557 for f in &td.fields {
2558 if let Some(named) = inner_named(&f.ty) {
2559 td_field_types.insert(f.name.clone(), named);
2560 }
2561 if is_vec_ty(&f.ty) {
2562 vec_field_names.insert(f.name.clone());
2563 }
2564 }
2565 if !td_field_types.is_empty() {
2566 field_types.insert(td.name.clone(), td_field_types);
2567 }
2568 }
2569 // Best-effort root-type detection: pick a unique TypeDef that contains all
2570 // `result_fields`. Falls back to `None` (renderer defaults to first-class
2571 // property syntax for unknown roots).
2572 let root_type = if e2e_config.result_fields.is_empty() {
2573 None
2574 } else {
2575 let matches: Vec<&alef_core::ir::TypeDef> = type_defs
2576 .iter()
2577 .filter(|td| {
2578 let names: HashSet<&str> = td.fields.iter().map(|f| f.name.as_str()).collect();
2579 e2e_config.result_fields.iter().all(|rf| names.contains(rf.as_str()))
2580 })
2581 .collect();
2582 if matches.len() == 1 {
2583 Some(matches[0].name.clone())
2584 } else {
2585 None
2586 }
2587 };
2588 SwiftFirstClassMap {
2589 first_class_types,
2590 field_types,
2591 vec_field_names,
2592 root_type,
2593 }
2594}
2595
2596#[cfg(test)]
2597mod tests {
2598 use super::*;
2599 use crate::field_access::FieldResolver;
2600 use std::collections::{HashMap, HashSet};
2601
2602 fn make_resolver_tool_calls() -> FieldResolver {
2603 // Resolver for `choices[0].message.tool_calls[0].function.name`:
2604 // - `choices` is a registered array field
2605 // - `choices.message.tool_calls` is optional (Optional<RustVec<ToolCall>>)
2606 let mut optional = HashSet::new();
2607 optional.insert("choices.message.tool_calls".to_string());
2608 let mut arrays = HashSet::new();
2609 arrays.insert("choices".to_string());
2610 FieldResolver::new(&HashMap::new(), &optional, &HashSet::new(), &arrays, &HashSet::new())
2611 }
2612
2613 /// Regression: after the optional `[0]` subscript, the codegen must NOT
2614 /// append a trailing `?`. The Swift compiler sees `?[0]` as consuming the
2615 /// optional chain, yielding the non-optional element type, so a subsequent
2616 /// `?.member` would trigger "cannot use optional chaining on non-optional
2617 /// value".
2618 ///
2619 /// With no `SwiftFirstClassMap` configured (default in this test), all
2620 /// types default to first-class property syntax — so accessors are
2621 /// `result.choices[0].message.toolCalls?[0].function.name` (no `()`).
2622 #[test]
2623 fn optional_vec_subscript_does_not_emit_trailing_question_mark_before_next_segment() {
2624 let resolver = make_resolver_tool_calls();
2625 let (accessor, has_optional) =
2626 swift_build_accessor("choices[0].message.tool_calls[0].function.name", "result", &resolver);
2627 // `?` before `[0]` is correct (tool_calls is optional). Property syntax
2628 // is the default when no SwiftFirstClassMap is supplied.
2629 assert!(
2630 accessor.contains("toolCalls?[0]"),
2631 "expected `toolCalls?[0]` for optional tool_calls, got: {accessor}"
2632 );
2633 // There must NOT be `?[0]?` (trailing `?` after the index).
2634 assert!(
2635 !accessor.contains("?[0]?"),
2636 "must not emit trailing `?` after subscript index: {accessor}"
2637 );
2638 // The expression IS optional overall (tool_calls may be nil).
2639 assert!(has_optional, "expected has_optional=true for optional field chain");
2640 // Subsequent member access uses `.` (non-optional chain) not `?.`.
2641 assert!(
2642 accessor.contains("[0].function"),
2643 "expected `.function` (non-optional) after subscript: {accessor}"
2644 );
2645 }
2646}