Skip to main content

alef_e2e/codegen/
streaming_assertions.rs

1//! Shared streaming-virtual-fields module for e2e test codegen.
2//!
3//! Chat-stream fixtures assert on "virtual" fields that don't exist on the
4//! stream result type itself — `chunks`, `chunks.length`, `stream_content`,
5//! `stream_complete`, `no_chunks_after_done`, `tool_calls`, `finish_reason`.
6//! These fields resolve against the *collected* list of chunks produced by
7//! draining the stream.
8//!
9//! [`StreamingFieldResolver`] provides two entry points:
10//! - [`StreamingFieldResolver::accessor`] — the language-specific expression
11//!   for a virtual field given a local variable that holds the collected list.
12//! - [`StreamingFieldResolver::collect_snippet`] — the language-specific
13//!   code snippet that drains a stream variable into the collected list.
14//!
15//! ## Convention
16//!
17//! The `chunks_var` parameter is the local variable name that holds the
18//! collected list (default: `"chunks"`).  The `stream_var` parameter is the
19//! result variable produced by the stream call (default: `"result"`).
20//!
21//! The set of streaming-virtual field names handled by this module:
22//! - `chunks`              → the collected list itself
23//! - `chunks.length`       → length/count of the collected list
24//! - `stream_content`      → concatenation of all delta content strings
25//! - `stream_complete`     → boolean — last chunk has a non-null finish_reason
26//! - `no_chunks_after_done` → structural invariant (true by construction for
27//!   channel/iterator-based APIs once the channel is closed; emitted as
28//!   `assert!(true)` / `assertTrue` for languages without post-DONE chunk plumbing)
29//! - `tool_calls`          → flat list of tool_calls from all chunk deltas
30//! - `finish_reason`       → finish_reason string from the last chunk
31
32/// The set of field names treated as streaming-virtual fields.
33pub const STREAMING_VIRTUAL_FIELDS: &[&str] = &[
34    "chunks",
35    "chunks.length",
36    "stream_content",
37    "stream_complete",
38    "no_chunks_after_done",
39    "tool_calls",
40    "finish_reason",
41];
42
43/// The set of streaming-virtual root names that may have deep-path continuations.
44///
45/// A field like `tool_calls[0].function.name` starts with `tool_calls` and has
46/// a continuation `[0].function.name`. These are handled by
47/// [`StreamingFieldResolver::accessor`] via the deep-path logic.
48///
49/// `usage` is a stream-level root: `usage.total_tokens` resolves against the
50/// last chunk that carried a usage payload (accessed via the collected chunks
51/// list). Python accessor: `(chunks[-1].usage if chunks else None)`.
52const STREAMING_VIRTUAL_ROOTS: &[&str] = &["tool_calls", "finish_reason"];
53
54/// Returns `true` when `field` is a streaming-virtual field name, including
55/// deep-nested paths that start with a known streaming-virtual root.
56///
57/// Examples that return `true`:
58/// - `"tool_calls"` (exact root)
59/// - `"tool_calls[0].function.name"` (deep path)
60/// - `"tool_calls[0].id"` (deep path)
61pub fn is_streaming_virtual_field(field: &str) -> bool {
62    if STREAMING_VIRTUAL_FIELDS.contains(&field) {
63        return true;
64    }
65    // Check deep-path prefixes: `tool_calls[…` or `tool_calls.`
66    for root in STREAMING_VIRTUAL_ROOTS {
67        if field.len() > root.len() && field.starts_with(root) {
68            let rest = &field[root.len()..];
69            if rest.starts_with('[') || rest.starts_with('.') {
70                return true;
71            }
72        }
73    }
74    false
75}
76
77/// Split a field path into `(root, tail)` when it starts with a streaming-virtual
78/// root and has a continuation.
79///
80/// Returns `None` when the field is an exact root match (no tail) or is not a
81/// streaming-virtual root at all.
82fn split_streaming_deep_path(field: &str) -> Option<(&str, &str)> {
83    for root in STREAMING_VIRTUAL_ROOTS {
84        if field.len() > root.len() && field.starts_with(root) {
85            let rest = &field[root.len()..];
86            if rest.starts_with('[') || rest.starts_with('.') {
87                return Some((root, rest));
88            }
89        }
90    }
91    None
92}
93
94/// Field names that unambiguously imply a streaming test (no overlap with
95/// non-streaming response shapes). `usage`, `tool_calls`, and `finish_reason`
96/// are intentionally excluded — they exist on non-streaming responses too
97/// (`usage.total_tokens` on ChatCompletionResponse, `choices[0].finish_reason`,
98/// etc.) and would otherwise drag non-streaming fixtures into streaming
99/// codegen.
100const STREAMING_ONLY_AUTO_DETECT_FIELDS: &[&str] = &[
101    "chunks",
102    "chunks.length",
103    "stream_content",
104    "stream_complete",
105    "no_chunks_after_done",
106];
107
108/// Resolve whether a fixture should be treated as streaming, honoring the
109/// call-level three-valued opt-in/out (`CallConfig::streaming`):
110///
111/// - `Some(true)` → forced streaming.
112/// - `Some(false)` → forced non-streaming (skip the auto-detect even when an
113///   assertion references a streaming-virtual-field name like `chunks`).
114/// - `None` → auto-detect: streaming iff the fixture has a streaming mock
115///   (`mock_response.stream_chunks`) or any assertion references one of the
116///   unambiguous streaming-only field names.
117///
118/// All backends should use this helper so the opt-out is respected uniformly.
119pub fn resolve_is_streaming(fixture: &crate::fixture::Fixture, call_streaming: Option<bool>) -> bool {
120    if let Some(forced) = call_streaming {
121        return forced;
122    }
123    fixture.is_streaming_mock()
124        || fixture.assertions.iter().any(|a| {
125            a.field
126                .as_deref()
127                .is_some_and(|f| !f.is_empty() && STREAMING_ONLY_AUTO_DETECT_FIELDS.contains(&f))
128        })
129}
130
131/// Shared streaming-virtual-fields resolver for e2e test codegen.
132pub struct StreamingFieldResolver;
133
134impl StreamingFieldResolver {
135    /// Returns the language-specific expression for a streaming-virtual field,
136    /// given `chunks_var` (the collected-list local name) and `lang`.
137    ///
138    /// Returns `None` when the field name is not a known streaming-virtual
139    /// field or the language has no streaming support.
140    pub fn accessor(field: &str, lang: &str, chunks_var: &str) -> Option<String> {
141        match field {
142            "chunks" => Some(match lang {
143                // Zig ArrayList does not expose .len directly; must use .items
144                "zig" => format!("{chunks_var}.items"),
145                // PHP variables require `$` sigil — bareword `chunks` is parsed as a
146                // constant reference and triggers "Undefined constant" errors.
147                "php" => format!("${chunks_var}"),
148                _ => chunks_var.to_string(),
149            }),
150
151            "chunks.length" => Some(match lang {
152                "rust" => format!("{chunks_var}.len()"),
153                "go" => format!("len({chunks_var})"),
154                "python" => format!("len({chunks_var})"),
155                "php" => format!("count(${chunks_var})"),
156                "elixir" => format!("length({chunks_var})"),
157                // kotlin List.size is a property (not .length)
158                "kotlin" => format!("{chunks_var}.size"),
159                // zig: chunks_var is ArrayList([]u8); use .items.len
160                "zig" => format!("{chunks_var}.items.len"),
161                // Swift Array uses .count (Collection protocol)
162                "swift" => format!("{chunks_var}.count"),
163                // node/wasm/typescript use .length
164                _ => format!("{chunks_var}.length"),
165            }),
166
167            "stream_content" => Some(match lang {
168                "rust" => {
169                    format!(
170                        "{chunks_var}.iter().map(|c| c.choices.first().and_then(|ch| ch.delta.content.as_deref()).unwrap_or(\"\")).collect::<String>()"
171                    )
172                }
173                "go" => {
174                    // Go: chunks is []pkg.ChatCompletionChunk
175                    format!(
176                        "func() string {{ var s string; for _, c := range {chunks_var} {{ if len(c.Choices) > 0 && c.Choices[0].Delta.Content != nil {{ s += *c.Choices[0].Delta.Content }} }}; return s }}()"
177                    )
178                }
179                "java" => {
180                    format!(
181                        "{chunks_var}.stream().map(c -> c.choices().stream().findFirst().map(ch -> ch.delta().content() != null ? ch.delta().content() : \"\").orElse(\"\")).collect(java.util.stream.Collectors.joining())"
182                    )
183                }
184                "php" => {
185                    format!("implode('', array_map(fn($c) => $c->choices[0]->delta->content ?? '', ${chunks_var}))")
186                }
187                "kotlin" => {
188                    // Kotlin: chunks is List<ChatCompletionChunk> (Java records via typealias).
189                    // choices() / delta() / content() are Java record accessor methods.
190                    format!(
191                        "{chunks_var}.joinToString(\"\") {{ it.choices()?.firstOrNull()?.delta()?.content() ?: \"\" }}"
192                    )
193                }
194                "kotlin_android" => {
195                    // kotlin-android: data classes use Kotlin property access (no parens).
196                    format!("{chunks_var}.joinToString(\"\") {{ it.choices?.firstOrNull()?.delta?.content ?: \"\" }}")
197                }
198                "elixir" => {
199                    // StreamDelta has all fields optional with skip_serializing_if, so
200                    // absent fields are not present as keys in the Jason-decoded map.
201                    // Use Map.get with defaults to avoid KeyError on absent :content.
202                    format!(
203                        "{chunks_var} |> Enum.map(fn c -> (Enum.at(c.choices, 0) || %{{}}) |> Map.get(:delta, %{{}}) |> Map.get(:content, \"\") end) |> Enum.join(\"\")"
204                    )
205                }
206                "python" => {
207                    format!("\"\".join(c.choices[0].delta.content or \"\" for c in {chunks_var} if c.choices)")
208                }
209                "zig" => {
210                    // Zig: `{chunks_var}_content` is a `std.ArrayList(u8)` populated by
211                    // the collect snippet. `.items` gives a `[]u8` slice of the content.
212                    format!("{chunks_var}_content.items")
213                }
214                // Swift: chunks is [ChatCompletionChunk] (swift-bridge class objects).
215                // choices() → RustVec<StreamChoice> (Collection, so .first is available).
216                // delta() → StreamDelta (non-optional). content() → RustString? → .toString().
217                "swift" => {
218                    format!(
219                        "{chunks_var}.map {{ c in c.choices().first.flatMap {{ ch in ch.delta().content()?.toString() }} ?? \"\" }}.joined()"
220                    )
221                }
222                // node/wasm/typescript
223                _ => {
224                    format!("{chunks_var}.map((c: any) => c.choices?.[0]?.delta?.content ?? '').join('')")
225                }
226            }),
227
228            "stream_complete" => Some(match lang {
229                "rust" => {
230                    format!(
231                        "{chunks_var}.last().and_then(|c| c.choices.first()).and_then(|ch| ch.finish_reason.as_ref()).is_some()"
232                    )
233                }
234                "go" => {
235                    format!(
236                        "func() bool {{ if len({chunks_var}) == 0 {{ return false }}; last := {chunks_var}[len({chunks_var})-1]; return len(last.Choices) > 0 && last.Choices[0].FinishReason != nil }}()"
237                    )
238                }
239                "java" => {
240                    format!(
241                        "!{chunks_var}.isEmpty() && {chunks_var}.get({chunks_var}.size()-1).choices().stream().findFirst().flatMap(ch -> java.util.Optional.ofNullable(ch.finishReason())).isPresent()"
242                    )
243                }
244                "php" => {
245                    // PHP streaming chunks come from `json_decode` of the binding's JSON
246                    // string return. The PHP binding serializes with rename_all = "camelCase",
247                    // so use `finishReason` (camelCase) to match the emitted JSON keys.
248                    format!("!empty(${chunks_var}) && isset(end(${chunks_var})->choices[0]->finishReason)")
249                }
250                "kotlin" => {
251                    // Kotlin: use isNotEmpty() + last() + safe-call chain
252                    format!(
253                        "{chunks_var}.isNotEmpty() && {chunks_var}.last().choices()?.firstOrNull()?.finishReason() != null"
254                    )
255                }
256                "kotlin_android" => {
257                    // kotlin-android: data classes use Kotlin property access (no parens).
258                    format!(
259                        "{chunks_var}.isNotEmpty() && {chunks_var}.last().choices?.firstOrNull()?.finishReason != null"
260                    )
261                }
262                "python" => {
263                    format!("bool({chunks_var}) and {chunks_var}[-1].choices[0].finish_reason is not None")
264                }
265                "elixir" => {
266                    format!("Enum.at(List.last({chunks_var}).choices, 0).finish_reason != nil")
267                }
268                // zig: the collect snippet exhausts the stream; check last chunk JSON
269                // was collected (chunks.items is non-empty) as a proxy for completion.
270                "zig" => {
271                    format!("{chunks_var}.items.len > 0")
272                }
273                // Swift: chunks is [ChatCompletionChunk] (swift-bridge class objects).
274                // choices() → RustVec<StreamChoice> (Collection), .first is Optional.
275                // finish_reason() → RustString? (non-nil when the stream completed).
276                "swift" => {
277                    format!("!{chunks_var}.isEmpty && {chunks_var}.last!.choices().first?.finish_reason() != nil")
278                }
279                // node/wasm/typescript
280                _ => {
281                    format!(
282                        "{chunks_var}.length > 0 && {chunks_var}[{chunks_var}.length - 1].choices?.[0]?.finishReason != null"
283                    )
284                }
285            }),
286
287            // no_chunks_after_done is a structural invariant: once the stream
288            // closes (channel drained / iterator exhausted), no further chunks
289            // can arrive.  We assert `true` as a compile-time proof of intent.
290            "no_chunks_after_done" => Some(match lang {
291                "rust" => "true".to_string(),
292                "go" => "true".to_string(),
293                "java" => "true".to_string(),
294                "php" => "true".to_string(),
295                _ => "true".to_string(),
296            }),
297
298            "tool_calls" => Some(match lang {
299                "rust" => {
300                    format!(
301                        "{chunks_var}.iter().flat_map(|c| c.choices.iter().flat_map(|ch| ch.delta.tool_calls.iter().flatten())).collect::<Vec<_>>()"
302                    )
303                }
304                "go" => {
305                    // StreamDelta.ToolCalls is `[]StreamToolCall` (slice, not pointer).
306                    // Return the typed slice so deep-path accessors like `tool_calls[0].function.name`
307                    // can index and access typed fields.
308                    format!(
309                        "func() []pkg.StreamToolCall {{ var tc []pkg.StreamToolCall; for _, c := range {chunks_var} {{ for _, ch := range c.Choices {{ tc = append(tc, ch.Delta.ToolCalls...) }} }}; return tc }}()"
310                    )
311                }
312                "java" => {
313                    format!(
314                        "{chunks_var}.stream().flatMap(c -> c.choices().stream()).flatMap(ch -> ch.delta().toolCalls() != null ? ch.delta().toolCalls().stream() : java.util.stream.Stream.empty()).toList()"
315                    )
316                }
317                "php" => {
318                    // PHP streaming chunks are json_decoded stdClass. The PHP binding
319                    // serializes with rename_all = "camelCase", so use `toolCalls`.
320                    format!(
321                        "array_merge(...array_map(fn($c) => $c->choices[0]->delta->toolCalls ?? [], ${chunks_var}))"
322                    )
323                }
324                "kotlin" => {
325                    // Kotlin: flatten tool_calls from all chunk deltas into one list
326                    format!(
327                        "{chunks_var}.flatMap {{ c -> c.choices()?.flatMap {{ ch -> ch.delta()?.toolCalls() ?: emptyList() }} ?: emptyList() }}"
328                    )
329                }
330                "kotlin_android" => {
331                    // kotlin-android: data classes use Kotlin property access (no parens).
332                    format!(
333                        "{chunks_var}.flatMap {{ c -> c.choices?.flatMap {{ ch -> ch.delta?.toolCalls ?: emptyList() }} ?: emptyList() }}"
334                    )
335                }
336                "python" => {
337                    format!(
338                        "[t for c in {chunks_var} for ch in (c.choices or []) for t in (ch.delta.tool_calls or [])]"
339                    )
340                }
341                "elixir" => {
342                    format!(
343                        "{chunks_var} |> Enum.flat_map(fn c -> (List.first(c.choices) || %{{}}).delta |> Map.get(:tool_calls, []) end)"
344                    )
345                }
346                // Zig: tool_calls count from all chunk deltas
347                "zig" => {
348                    format!("{chunks_var}.items")
349                }
350                // Swift: chunks is [ChatCompletionChunk] (swift-bridge class objects).
351                // choices() → RustVec<StreamChoice> (Collection). delta() → StreamDelta.
352                // tool_calls() → RustVec<StreamToolCall>?
353                // Use an explicit return type annotation on the outer closure so Swift's
354                // type checker doesn't pick the Optional overload of flatMap.  Guard-let
355                // unwrapping avoids ambiguous ?? operator precedence.
356                "swift" => {
357                    format!(
358                        "{chunks_var}.flatMap {{ c -> [StreamToolCallRef] in guard let ch = c.choices().first, let tcs = ch.delta().tool_calls() else {{ return [] }}; return Array(tcs) }}"
359                    )
360                }
361                _ => {
362                    format!("{chunks_var}.flatMap((c: any) => c.choices?.[0]?.delta?.toolCalls ?? [])")
363                }
364            }),
365
366            "finish_reason" => Some(match lang {
367                "rust" => {
368                    // ChatCompletionChunk's finish_reason is Option<FinishReason> (enum, not
369                    // String). Display impl writes the JSON wire form (e.g. "tool_calls").
370                    format!(
371                        "{chunks_var}.last().and_then(|c| c.choices.first()).and_then(|ch| ch.finish_reason.as_ref()).map(|v| v.to_string()).unwrap_or_default()"
372                    )
373                }
374                "go" => {
375                    // FinishReason is a typed alias (`type FinishReason string`) in bindings,
376                    // so cast to string explicitly to match the assertion target type.
377                    format!(
378                        "func() string {{ if len({chunks_var}) == 0 {{ return \"\" }}; last := {chunks_var}[len({chunks_var})-1]; if len(last.Choices) > 0 && last.Choices[0].FinishReason != nil {{ return string(*last.Choices[0].FinishReason) }}; return \"\" }}()"
379                    )
380                }
381                "java" => {
382                    // FinishReason.getValue() returns the JSON wire string (e.g. "tool_calls").
383                    // Without it, assertEquals(String, FinishReason) fails because Object.equals
384                    // doesn't cross types even when toString() matches.
385                    format!(
386                        "({chunks_var}.isEmpty() ? null : {chunks_var}.get({chunks_var}.size()-1).choices().stream().findFirst().map(ch -> ch.finishReason() == null ? null : ch.finishReason().getValue()).orElse(null))"
387                    )
388                }
389                "php" => {
390                    // PHP streaming chunks are json_decoded stdClass. The PHP binding
391                    // serializes with rename_all = "camelCase", so use `finishReason`.
392                    format!("(!empty(${chunks_var}) ? (end(${chunks_var})->choices[0]->finishReason ?? null) : null)")
393                }
394                "kotlin" => {
395                    // Returns the string value of the finish_reason enum from the last chunk.
396                    // FinishReason.getValue() returns the JSON wire string (e.g. "tool_calls").
397                    format!(
398                        "(if ({chunks_var}.isEmpty()) null else {chunks_var}.last().choices()?.firstOrNull()?.finishReason()?.getValue())"
399                    )
400                }
401                "kotlin_android" => {
402                    // kotlin-android: plain Kotlin enum class uses .name.lowercase() for wire string.
403                    format!(
404                        "(if ({chunks_var}.isEmpty()) null else {chunks_var}.last().choices?.firstOrNull()?.finishReason?.name?.lowercase())"
405                    )
406                }
407                "python" => {
408                    // FinishReason is a PyO3 enum object, not a plain string.
409                    // Wrap in str() so callers can do `.strip()` / string comparisons
410                    // without `AttributeError: 'FinishReason' has no attribute 'strip'`.
411                    format!(
412                        "(str({chunks_var}[-1].choices[0].finish_reason) if {chunks_var} and {chunks_var}[-1].choices else None)"
413                    )
414                }
415                "elixir" => {
416                    format!("Enum.at(List.last({chunks_var}).choices, 0).finish_reason")
417                }
418                // Zig: finish_reason from the last chunk's JSON via an inline labeled block.
419                // Returns `[]const u8` (unwrapped with orelse "" for expectEqualStrings).
420                "zig" => {
421                    format!(
422                        "(blk: {{ if ({chunks_var}.items.len == 0) break :blk \"\"; var _lcp = std.json.parseFromSlice(std.json.Value, std.heap.c_allocator, {chunks_var}.items[{chunks_var}.items.len - 1], .{{}}) catch break :blk \"\"; defer _lcp.deinit(); if (_lcp.value.object.get(\"choices\")) |_lchs| if (_lchs.array.items.len > 0) if (_lchs.array.items[0].object.get(\"finish_reason\")) |_fr| if (_fr == .string) break :blk _fr.string; break :blk \"\"; }})"
423                    )
424                }
425                // Swift: FinishReason is a swift-bridge opaque class with .to_string() → RustString.
426                // finish_reason() on StreamChoiceRef returns RustString? (the raw wire string).
427                // Return nil when chunks is empty or the last choice has no finish_reason.
428                "swift" => {
429                    format!(
430                        "({chunks_var}.isEmpty ? nil : {chunks_var}.last!.choices().first?.finish_reason()?.toString())"
431                    )
432                }
433                _ => {
434                    format!(
435                        "{chunks_var}.length > 0 ? {chunks_var}[{chunks_var}.length - 1].choices?.[0]?.finishReason : undefined"
436                    )
437                }
438            }),
439
440            // `usage` is a stream-level virtual root: resolves against the last
441            // chunk that carried a usage payload.  Deep paths like `usage.total_tokens`
442            // are handled by the deep-path logic in the `_` arm below (root=`usage`,
443            // tail=`.total_tokens`), which calls this base accessor and appends the tail.
444            "usage" => Some(match lang {
445                "python" => {
446                    // Access the last chunk's usage object (may be None).
447                    // Deep paths like usage.total_tokens are rendered as:
448                    //   (chunks[-1].usage if chunks else None).total_tokens
449                    format!("({chunks_var}[-1].usage if {chunks_var} else None)")
450                }
451                "rust" => {
452                    format!("{chunks_var}.last().and_then(|c| c.usage.as_ref())")
453                }
454                "go" => {
455                    format!(
456                        "func() interface{{}} {{ if len({chunks_var}) == 0 {{ return nil }}; return {chunks_var}[len({chunks_var})-1].Usage }}()"
457                    )
458                }
459                "java" => {
460                    format!("({chunks_var}.isEmpty() ? null : {chunks_var}.get({chunks_var}.size()-1).usage())")
461                }
462                "kotlin" => {
463                    format!("(if ({chunks_var}.isEmpty()) null else {chunks_var}.last().usage())")
464                }
465                "kotlin_android" => {
466                    // kotlin-android: data classes use Kotlin property access (no parens).
467                    format!("(if ({chunks_var}.isEmpty()) null else {chunks_var}.last().usage)")
468                }
469                "php" => {
470                    format!("(!empty(${chunks_var}) ? end(${chunks_var})->usage ?? null : null)")
471                }
472                "elixir" => {
473                    format!("(if length({chunks_var}) > 0, do: List.last({chunks_var}).usage, else: nil)")
474                }
475                // Swift: usage() on ChatCompletionChunkRef returns Usage? (swift-bridge class).
476                "swift" => {
477                    format!("({chunks_var}.isEmpty ? nil : {chunks_var}.last!.usage())")
478                }
479                _ => {
480                    format!("({chunks_var}.length > 0 ? {chunks_var}[{chunks_var}.length - 1].usage : undefined)")
481                }
482            }),
483
484            _ => {
485                // Deep-path: e.g. `tool_calls[0].function.name`
486                // Split into root + tail, get the root's inline expression, then
487                // render the tail (index + fields) in a per-language style on top.
488                if let Some((root, tail)) = split_streaming_deep_path(field) {
489                    // Rust needs Option-aware chaining for the StreamToolCall fields
490                    // (function/id are Option<...>). The generic tail renderer can't
491                    // infer Option-wrapping, so we emit rust-specific idiom here.
492                    if lang == "rust" && root == "tool_calls" {
493                        return Some(render_rust_tool_calls_deep(chunks_var, tail));
494                    }
495                    // Swift: StreamToolCallRef fields are swift-bridge methods returning
496                    // Optional.  The generic render_deep_tail doesn't know to add `()`
497                    // or optional-chain with `?.`, so use a dedicated renderer.
498                    if lang == "swift" && root == "tool_calls" {
499                        let root_expr = Self::accessor(root, lang, chunks_var)?;
500                        return Some(render_swift_tool_calls_deep(&root_expr, tail));
501                    }
502                    // Zig stores stream chunks as JSON strings (`[]const u8`) in
503                    // `chunks: ArrayList([]u8)`, not typed `ChatCompletionChunk`
504                    // records. A deep `tool_calls[N].function.name` access would
505                    // require parsing each chunk's JSON inline — rather than
506                    // emit code that won't compile, signal "unsupported" so the
507                    // assertion is skipped at the call site.
508                    if lang == "zig" && root == "tool_calls" {
509                        return None;
510                    }
511                    let root_expr = Self::accessor(root, lang, chunks_var)?;
512                    Some(render_deep_tail(&root_expr, tail, lang))
513                } else {
514                    None
515                }
516            }
517        }
518    }
519
520    /// Returns the language-specific stream-collect-into-list snippet that
521    /// produces `chunks_var` from `stream_var`.
522    ///
523    /// Returns `None` when the language has no streaming collect support or
524    /// when the collect snippet cannot be expressed generically.
525    pub fn collect_snippet(lang: &str, stream_var: &str, chunks_var: &str) -> Option<String> {
526        match lang {
527            "rust" => Some(format!(
528                "let {chunks_var}: Vec<_> = tokio_stream::StreamExt::collect::<Vec<_>>({stream_var}).await\n        .into_iter()\n        .map(|r| r.expect(\"stream item failed\"))\n        .collect();"
529            )),
530            "go" => Some(format!(
531                "var {chunks_var} []pkg.ChatCompletionChunk\n\tfor chunk := range {stream_var} {{\n\t\t{chunks_var} = append({chunks_var}, chunk)\n\t}}"
532            )),
533            "java" => Some(format!(
534                "var {chunks_var} = new java.util.ArrayList<ChatCompletionChunk>();\n        var _it = {stream_var}.iterator();\n        while (_it.hasNext()) {{ {chunks_var}.add(_it.next()); }}"
535            )),
536            // PHP binding's chat_stream_async typically returns a JSON string of the
537            // chunk array (PHP cannot expose Rust iterators directly via ext-php-rs).
538            // Decode to an array of stdClass objects so accessor chains like
539            // `$c->choices[0]->delta->content` resolve against the JSON wire shape
540            // (snake_case keys).  Falls back to iterator_to_array for a future binding
541            // upgrade that exposes a real iterator.
542            "php" => Some(format!(
543                "${chunks_var} = is_string(${stream_var}) ? (json_decode(${stream_var}) ?: []) : iterator_to_array(${stream_var});"
544            )),
545            "python" => Some(format!(
546                "{chunks_var} = []\n    async for chunk in {stream_var}:\n        {chunks_var}.append(chunk)"
547            )),
548            "kotlin" => {
549                // Kotlin: chatStream returns Iterator<ChatCompletionChunk> (from Java bridge).
550                // Drain into a Kotlin List using asSequence().toList().
551                Some(format!("val {chunks_var} = {stream_var}.asSequence().toList()"))
552            }
553            "kotlin_android" => {
554                // kotlin-android: chatStream returns Flow<ChatCompletionChunk> (kotlinx.coroutines).
555                // Collect inside a runBlocking coroutine scope using Flow.toList().
556                Some(format!("val {chunks_var} = {stream_var}.toList()"))
557            }
558            "elixir" => Some(format!("{chunks_var} = Enum.to_list({stream_var})")),
559            "node" | "wasm" | "typescript" => Some(format!(
560                "const {chunks_var}: any[] = [];\n    for await (const _chunk of {stream_var}) {{ {chunks_var}.push(_chunk); }}"
561            )),
562            "swift" => {
563                // Swift's chat-stream wrapper returns AsyncThrowingStream<ChunkType, Error>,
564                // so consumers drain it with `for try await chunk in stream { ... }`. The
565                // chunk type is decoded from the bridge-boundary JSON inside the wrapper —
566                // here we just collect the typed Swift values.
567                Some(format!(
568                    "var {chunks_var}: [ChatCompletionChunk] = []\n        for try await _chunk in {stream_var} {{ {chunks_var}.append(_chunk) }}"
569                ))
570            }
571            "zig" => Some(Self::collect_snippet_zig(stream_var, chunks_var, "module", "ffi")),
572            _ => None,
573        }
574    }
575
576    /// Render Zig's streaming collect snippet using the configured module and FFI prefix.
577    pub fn collect_snippet_zig(stream_var: &str, chunks_var: &str, module_name: &str, ffi_prefix: &str) -> String {
578        let stream_next = format!("{ffi_prefix}_default_client_chat_stream_next");
579        let chunk_to_json = format!("{ffi_prefix}_chat_completion_chunk_to_json");
580        let chunk_free = format!("{ffi_prefix}_chat_completion_chunk_free");
581        let free_string = format!("{ffi_prefix}_free_string");
582
583        // Zig 0.16: ArrayList is unmanaged — no stored allocator.
584        // Use `.empty` to initialize, pass `std.heap.c_allocator` to each mutation.
585        // `stream_var` is the opaque stream handle obtained via `_start`.
586        // We collect every chunk's JSON string into `chunks_var: ArrayList([]u8)`
587        // and concatenate delta content into `{chunks_var}_content: ArrayList(u8)`.
588        // Accessors use `.items.len` and `{chunks_var}_content.items` on these lists.
589        format!(
590            concat!(
591                "var {chunks_var}: std.ArrayList([]u8) = .empty;
592",
593                "    defer {{
594",
595                "        for ({chunks_var}.items) |_cj| std.heap.c_allocator.free(_cj);
596",
597                "        {chunks_var}.deinit(std.heap.c_allocator);
598",
599                "    }}
600",
601                "    var {chunks_var}_content: std.ArrayList(u8) = .empty;
602",
603                "    defer {chunks_var}_content.deinit(std.heap.c_allocator);
604",
605                "    while (true) {{
606",
607                "        const _nc = {module_name}.c.{stream_next}({stream_var});
608",
609                "        if (_nc == null) break;
610",
611                "        const _np = {module_name}.c.{chunk_to_json}(_nc);
612",
613                "        {module_name}.c.{chunk_free}(_nc);
614",
615                "        if (_np == null) continue;
616",
617                "        const _ns = std.mem.span(_np);
618",
619                "        const _nj = try std.heap.c_allocator.dupe(u8, _ns);
620",
621                "        {module_name}.c.{free_string}(_np);
622",
623                "        if (std.json.parseFromSlice(std.json.Value, std.heap.c_allocator, _nj, .{{}})) |_cp| {{
624",
625                "            defer _cp.deinit();
626",
627                "            if (_cp.value.object.get(\"choices\")) |_chs|
628",
629                "                if (_chs.array.items.len > 0)
630",
631                "                    if (_chs.array.items[0].object.get(\"delta\")) |_dl|
632",
633                "                        if (_dl.object.get(\"content\")) |_ct|
634",
635                "                            if (_ct == .string) try {chunks_var}_content.appendSlice(std.heap.c_allocator, _ct.string);
636",
637                "        }} else |_| {{}}
638",
639                "        try {chunks_var}.append(std.heap.c_allocator, _nj);
640",
641                "    }}"
642            ),
643            chunks_var = chunks_var,
644            stream_var = stream_var,
645            module_name = module_name,
646            stream_next = stream_next,
647            chunk_to_json = chunk_to_json,
648            chunk_free = chunk_free,
649            free_string = free_string,
650        )
651    }
652}
653
654/// Render a Swift deep accessor for `tool_calls[N]...` paths.
655///
656/// The flat tool_calls array is `[StreamToolCallRef]`.  Each element is a
657/// swift-bridge opaque ref: the first field after an index (e.g. `function`)
658/// is accessed with `.method()` (direct call on the non-optional ref).
659/// All subsequent fields use `?.method()` (optional chaining) because each
660/// intermediate method returns `Optional`.  The string leaf appends
661/// `?.toString()` to convert `RustString?` to `String?`.
662///
663/// Example: `[0].function.name`
664///   → `(root)[0].function()?.name()?.toString()`
665fn render_swift_tool_calls_deep(root_expr: &str, tail: &str) -> String {
666    use heck::ToLowerCamelCase;
667    let segs = parse_tail(tail);
668    let mut expr = root_expr.to_string();
669    let last_field_idx = segs.iter().rposition(|s| matches!(s, TailSeg::Field(_)));
670    // Track whether the previous segment was an index (non-optional element),
671    // which means the next field uses `.method()` not `?.method()`.
672    let mut prev_was_index = false;
673
674    for (i, seg) in segs.iter().enumerate() {
675        match seg {
676            TailSeg::Index(n) => {
677                expr = format!("({expr})[{n}]");
678                prev_was_index = true;
679            }
680            TailSeg::Field(f) => {
681                let method = f.to_lower_camel_case();
682                let is_last = Some(i) == last_field_idx;
683                let chain = if prev_was_index { "." } else { "?." };
684                if is_last {
685                    // Leaf: string field — call + optional-chain .toString()
686                    expr = format!("{expr}{chain}{method}()?.toString()");
687                } else {
688                    // Intermediate object field
689                    expr = format!("{expr}{chain}{method}()");
690                }
691                prev_was_index = false;
692            }
693        }
694    }
695    expr
696}
697
698/// Render a rust deep accessor for `tool_calls[N]...` paths over the flattened
699/// stream-chunk tool_calls iterator. Handles Option-wrapped fields by chaining
700/// `as_ref().and_then(...)` so the final value is a `&str` (for name/id/arguments).
701fn render_rust_tool_calls_deep(chunks_var: &str, tail: &str) -> String {
702    let segs = parse_tail(tail);
703    // Locate index segment (rust uses .nth(n) on the iterator instead of [N] on a Vec)
704    let idx = segs.iter().find_map(|s| match s {
705        TailSeg::Index(n) => Some(*n),
706        _ => None,
707    });
708    let field_segs: Vec<&str> = segs
709        .iter()
710        .filter_map(|s| match s {
711            TailSeg::Field(f) => Some(f.as_str()),
712            _ => None,
713        })
714        .collect();
715
716    let base = format!(
717        "{chunks_var}.iter().flat_map(|c| c.choices.iter().flat_map(|ch| ch.delta.tool_calls.iter().flatten()))"
718    );
719    let with_nth = match idx {
720        Some(n) => format!("{base}.nth({n})"),
721        None => base,
722    };
723
724    // Chain Option-aware field access. Every field on StreamToolCall is Option<...>;
725    // the leaf (String fields) uses `.as_deref()` to project to `&str`.
726    let mut expr = with_nth;
727    for (i, f) in field_segs.iter().enumerate() {
728        let is_leaf = i == field_segs.len() - 1;
729        if is_leaf {
730            expr = format!("{expr}.and_then(|x| x.{f}.as_deref())");
731        } else {
732            expr = format!("{expr}.and_then(|x| x.{f}.as_ref())");
733        }
734    }
735    format!("{expr}.unwrap_or(\"\")")
736}
737
738/// Parse a deep-path tail (e.g. `[0].function.name`) into structured segments.
739///
740/// The tail always starts with either `[N]` (array index) or `.field`.
741/// Returns a list of segments: `TailSeg::Index(N)` or `TailSeg::Field(name)`.
742#[derive(Debug, PartialEq)]
743enum TailSeg {
744    Index(usize),
745    Field(String),
746}
747
748fn parse_tail(tail: &str) -> Vec<TailSeg> {
749    let mut segs = Vec::new();
750    let mut rest = tail;
751    while !rest.is_empty() {
752        if let Some(inner) = rest.strip_prefix('[') {
753            // Array index: `[N]`
754            if let Some(close) = inner.find(']') {
755                let idx_str = &inner[..close];
756                if let Ok(idx) = idx_str.parse::<usize>() {
757                    segs.push(TailSeg::Index(idx));
758                }
759                rest = &inner[close + 1..];
760            } else {
761                break;
762            }
763        } else if let Some(inner) = rest.strip_prefix('.') {
764            // Field name: up to next `.` or `[`
765            let end = inner.find(['.', '[']).unwrap_or(inner.len());
766            segs.push(TailSeg::Field(inner[..end].to_string()));
767            rest = &inner[end..];
768        } else {
769            break;
770        }
771    }
772    segs
773}
774
775/// Render the full deep accessor expression by appending per-language tail
776/// segments onto `root_expr`.
777fn render_deep_tail(root_expr: &str, tail: &str, lang: &str) -> String {
778    use heck::{ToLowerCamelCase, ToPascalCase};
779
780    let segs = parse_tail(tail);
781    let mut out = root_expr.to_string();
782
783    for seg in &segs {
784        match (seg, lang) {
785            (TailSeg::Index(n), "rust") => {
786                out = format!("({out})[{n}]");
787            }
788            (TailSeg::Index(n), "java") => {
789                out = format!("({out}).get({n})");
790            }
791            (TailSeg::Index(n), "kotlin") => {
792                if *n == 0 {
793                    out = format!("({out}).first()");
794                } else {
795                    out = format!("({out}).get({n})");
796                }
797            }
798            (TailSeg::Index(n), "kotlin_android") => {
799                if *n == 0 {
800                    out = format!("({out}).first()");
801                } else {
802                    out = format!("({out})[{n}]");
803                }
804            }
805            (TailSeg::Index(n), "elixir") => {
806                out = format!("Enum.at({out}, {n})");
807            }
808            (TailSeg::Index(n), "zig") => {
809                out = format!("({out}).items[{n}]");
810            }
811            (TailSeg::Index(n), "php") => {
812                out = format!("({out})[{n}]");
813            }
814            (TailSeg::Index(n), _) => {
815                // rust-like for go (but we handle Field differently), python, node, ts, kotlin, etc.
816                out = format!("({out})[{n}]");
817            }
818            (TailSeg::Field(f), "rust") => {
819                use heck::ToSnakeCase;
820                out.push('.');
821                out.push_str(&f.to_snake_case());
822            }
823            (TailSeg::Field(f), "go") => {
824                use alef_codegen::naming::to_go_name;
825                out.push('.');
826                out.push_str(&to_go_name(f));
827            }
828            (TailSeg::Field(f), "java") => {
829                out.push('.');
830                out.push_str(&f.to_lower_camel_case());
831                out.push_str("()");
832            }
833            (TailSeg::Field(f), "kotlin") => {
834                // Use safe-call `?.` for all field accessors in Kotlin deep paths.
835                // All streaming tool-call sub-fields (`function`, `id`, `name`,
836                // `arguments`) are nullable in the generated Java records, so `?.`
837                // is always correct here and prevents "non-null asserted call on
838                // nullable receiver" compile errors.
839                out.push_str("?.");
840                out.push_str(&f.to_lower_camel_case());
841                out.push_str("()");
842            }
843            (TailSeg::Field(f), "kotlin_android") => {
844                // kotlin-android: Kotlin data classes use property access (no parens).
845                out.push_str("?.");
846                out.push_str(&f.to_lower_camel_case());
847            }
848            (TailSeg::Field(f), "csharp") => {
849                out.push('.');
850                out.push_str(&f.to_pascal_case());
851            }
852            (TailSeg::Field(f), "php") => {
853                // Streaming PHP accessors operate on json_decoded stdClass with
854                // snake_case property names (JSON wire format), not the camelCase
855                // properties exposed on the PHP wrapper class. Use the raw field
856                // name verbatim.
857                out.push_str("->");
858                out.push_str(f);
859            }
860            (TailSeg::Field(f), "elixir") => {
861                out.push('.');
862                out.push_str(f);
863            }
864            (TailSeg::Field(f), "zig") => {
865                out.push('.');
866                out.push_str(f);
867            }
868            (TailSeg::Field(f), "python") | (TailSeg::Field(f), "ruby") => {
869                out.push('.');
870                out.push_str(f);
871            }
872            // node, wasm, typescript, kotlin, dart, swift all use camelCase
873            (TailSeg::Field(f), _) => {
874                out.push('.');
875                out.push_str(&f.to_lower_camel_case());
876            }
877        }
878    }
879
880    out
881}
882
883#[cfg(test)]
884mod tests {
885    use super::*;
886
887    #[test]
888    fn is_streaming_virtual_field_recognizes_all_fields() {
889        for field in STREAMING_VIRTUAL_FIELDS {
890            assert!(
891                is_streaming_virtual_field(field),
892                "field '{field}' not recognized as streaming virtual"
893            );
894        }
895    }
896
897    #[test]
898    fn is_streaming_virtual_field_rejects_real_fields() {
899        assert!(!is_streaming_virtual_field("content"));
900        assert!(!is_streaming_virtual_field("choices"));
901        assert!(!is_streaming_virtual_field("model"));
902        assert!(!is_streaming_virtual_field(""));
903    }
904
905    #[test]
906    fn is_streaming_virtual_field_rejects_non_root_paths_with_matching_tail() {
907        // Regression: prior impl matched any field whose chars-after-root-len started
908        // with `[` or `.` — without checking that the field actually starts with the
909        // root token. `choices[0].finish_reason` therefore falsely matched root
910        // `tool_calls` because byte 10 onward is `.finish_reason`.
911        assert!(!is_streaming_virtual_field("choices[0].finish_reason"));
912        assert!(!is_streaming_virtual_field("choices[0].message.content"));
913        assert!(!is_streaming_virtual_field("data[0].embedding"));
914    }
915
916    #[test]
917    fn is_streaming_virtual_field_does_not_match_usage() {
918        // `usage` is intentionally NOT a streaming-virtual root: chat/embed
919        // responses carry `usage.total_tokens` at the response root, so treating
920        // it as virtual would drag non-streaming tests into the chunks accessor.
921        assert!(!is_streaming_virtual_field("usage"));
922        assert!(!is_streaming_virtual_field("usage.total_tokens"));
923        assert!(!is_streaming_virtual_field("usage.prompt_tokens"));
924    }
925
926    #[test]
927    fn accessor_chunks_returns_var_name() {
928        assert_eq!(
929            StreamingFieldResolver::accessor("chunks", "rust", "chunks"),
930            Some("chunks".to_string())
931        );
932        assert_eq!(
933            StreamingFieldResolver::accessor("chunks", "node", "chunks"),
934            Some("chunks".to_string())
935        );
936    }
937
938    #[test]
939    fn accessor_chunks_length_uses_language_idiom() {
940        let rust = StreamingFieldResolver::accessor("chunks.length", "rust", "chunks").unwrap();
941        assert!(rust.contains(".len()"), "rust: {rust}");
942
943        let go = StreamingFieldResolver::accessor("chunks.length", "go", "chunks").unwrap();
944        assert!(go.starts_with("len("), "go: {go}");
945
946        let node = StreamingFieldResolver::accessor("chunks.length", "node", "chunks").unwrap();
947        assert!(node.contains(".length"), "node: {node}");
948
949        let php = StreamingFieldResolver::accessor("chunks.length", "php", "chunks").unwrap();
950        assert!(php.starts_with("count("), "php: {php}");
951    }
952
953    #[test]
954    fn accessor_chunks_length_zig_uses_items_len() {
955        let zig = StreamingFieldResolver::accessor("chunks.length", "zig", "chunks").unwrap();
956        assert_eq!(zig, "chunks.items.len", "zig chunks.length: {zig}");
957    }
958
959    #[test]
960    fn accessor_stream_content_zig_uses_content_items() {
961        let zig = StreamingFieldResolver::accessor("stream_content", "zig", "chunks").unwrap();
962        assert_eq!(zig, "chunks_content.items", "zig stream_content: {zig}");
963    }
964
965    #[test]
966    fn collect_snippet_zig_drains_via_ffi() {
967        let snip = StreamingFieldResolver::collect_snippet("zig", "_stream_handle", "chunks").unwrap();
968        assert!(snip.contains("std.ArrayList([]u8)"), "zig collect: {snip}");
969        assert!(snip.contains("chat_stream_next(_stream_handle)"), "zig collect: {snip}");
970        assert!(snip.contains("chunks_content"), "zig collect: {snip}");
971        assert!(
972            snip.contains("chunks.append(std.heap.c_allocator"),
973            "zig collect: {snip}"
974        );
975        assert!(snip.contains(".empty;"), "zig collect (Zig 0.16 unmanaged): {snip}");
976    }
977
978    #[test]
979    fn accessor_stream_content_rust_uses_iterator() {
980        let expr = StreamingFieldResolver::accessor("stream_content", "rust", "chunks").unwrap();
981        assert!(expr.contains(".collect::<String>()"), "rust stream_content: {expr}");
982    }
983
984    #[test]
985    fn accessor_no_chunks_after_done_returns_true() {
986        for lang in ["rust", "go", "java", "php", "node", "wasm", "elixir"] {
987            let expr = StreamingFieldResolver::accessor("no_chunks_after_done", lang, "chunks").unwrap();
988            assert_eq!(expr, "true", "lang {lang}: expected 'true', got '{expr}'");
989        }
990    }
991
992    #[test]
993    fn accessor_elixir_chunks_length_uses_length_function() {
994        let expr = StreamingFieldResolver::accessor("chunks.length", "elixir", "chunks").unwrap();
995        assert_eq!(expr, "length(chunks)", "elixir chunks.length: {expr}");
996    }
997
998    #[test]
999    fn accessor_elixir_stream_content_uses_pipe() {
1000        let expr = StreamingFieldResolver::accessor("stream_content", "elixir", "chunks").unwrap();
1001        assert!(expr.contains("|> Enum.join"), "elixir stream_content: {expr}");
1002        assert!(expr.contains("|> Enum.map"), "elixir stream_content: {expr}");
1003        // Elixir lists do not support bracket access — must use Enum.at, never choices[0]
1004        assert!(
1005            !expr.contains("choices[0]"),
1006            "elixir stream_content must not use bracket access on list: {expr}"
1007        );
1008        assert!(
1009            expr.contains("Enum.at("),
1010            "elixir stream_content must use Enum.at for list index: {expr}"
1011        );
1012    }
1013
1014    #[test]
1015    fn accessor_elixir_stream_complete_uses_list_last() {
1016        let expr = StreamingFieldResolver::accessor("stream_complete", "elixir", "chunks").unwrap();
1017        assert!(expr.contains("List.last(chunks)"), "elixir stream_complete: {expr}");
1018        assert!(expr.contains("finish_reason != nil"), "elixir stream_complete: {expr}");
1019        // Elixir lists do not support bracket access — must use Enum.at, never choices[0]
1020        assert!(
1021            !expr.contains("choices[0]"),
1022            "elixir stream_complete must not use bracket access on list: {expr}"
1023        );
1024        assert!(
1025            expr.contains("Enum.at("),
1026            "elixir stream_complete must use Enum.at for list index: {expr}"
1027        );
1028    }
1029
1030    #[test]
1031    fn accessor_elixir_finish_reason_uses_list_last() {
1032        let expr = StreamingFieldResolver::accessor("finish_reason", "elixir", "chunks").unwrap();
1033        assert!(expr.contains("List.last(chunks)"), "elixir finish_reason: {expr}");
1034        assert!(expr.contains("finish_reason"), "elixir finish_reason: {expr}");
1035        // Elixir lists do not support bracket access — must use Enum.at, never choices[0]
1036        assert!(
1037            !expr.contains("choices[0]"),
1038            "elixir finish_reason must not use bracket access on list: {expr}"
1039        );
1040        assert!(
1041            expr.contains("Enum.at("),
1042            "elixir finish_reason must use Enum.at for list index: {expr}"
1043        );
1044    }
1045
1046    #[test]
1047    fn collect_snippet_elixir_uses_enum_to_list() {
1048        let snip = StreamingFieldResolver::collect_snippet("elixir", "result", "chunks").unwrap();
1049        assert!(snip.contains("Enum.to_list(result)"), "elixir: {snip}");
1050        assert!(snip.contains("chunks ="), "elixir: {snip}");
1051    }
1052
1053    #[test]
1054    fn collect_snippet_rust_uses_tokio_stream() {
1055        let snip = StreamingFieldResolver::collect_snippet("rust", "result", "chunks").unwrap();
1056        assert!(snip.contains("tokio_stream::StreamExt::collect"), "rust: {snip}");
1057        assert!(snip.contains("let chunks"), "rust: {snip}");
1058        // Items are Result<ChatCompletionChunk, _> — unwrap so chunks is Vec<ChatCompletionChunk>
1059        assert!(snip.contains(".expect("), "rust must unwrap Result items: {snip}");
1060    }
1061
1062    #[test]
1063    fn collect_snippet_go_drains_channel() {
1064        let snip = StreamingFieldResolver::collect_snippet("go", "stream", "chunks").unwrap();
1065        assert!(snip.contains("for chunk := range stream"), "go: {snip}");
1066    }
1067
1068    #[test]
1069    fn collect_snippet_java_uses_iterator() {
1070        let snip = StreamingFieldResolver::collect_snippet("java", "result", "chunks").unwrap();
1071        // Must call .iterator() on the Stream<T> before using hasNext()/next() —
1072        // Stream does not implement those methods directly.
1073        assert!(
1074            snip.contains(".iterator()"),
1075            "java snippet must call .iterator() on stream: {snip}"
1076        );
1077        assert!(snip.contains("hasNext()"), "java: {snip}");
1078        assert!(snip.contains(".next()"), "java: {snip}");
1079    }
1080
1081    #[test]
1082    fn collect_snippet_php_decodes_json_or_iterates() {
1083        let snip = StreamingFieldResolver::collect_snippet("php", "result", "chunks").unwrap();
1084        // PHP binding's chat_stream_async returns a JSON string today; collect-snippet
1085        // decodes it.  iterator_to_array is retained as the fallback branch so a
1086        // future binding that exposes a real iterator continues to work without
1087        // regenerating the e2e tests.
1088        assert!(snip.contains("json_decode"), "php must decode JSON: {snip}");
1089        assert!(
1090            snip.contains("iterator_to_array"),
1091            "php must keep iterator_to_array fallback: {snip}"
1092        );
1093        assert!(snip.contains("$chunks ="), "php must bind $chunks: {snip}");
1094    }
1095
1096    #[test]
1097    fn collect_snippet_node_uses_for_await() {
1098        let snip = StreamingFieldResolver::collect_snippet("node", "result", "chunks").unwrap();
1099        assert!(snip.contains("for await"), "node: {snip}");
1100    }
1101
1102    #[test]
1103    fn collect_snippet_python_uses_async_for() {
1104        let snip = StreamingFieldResolver::collect_snippet("python", "result", "chunks").unwrap();
1105        assert!(snip.contains("async for chunk in result"), "python: {snip}");
1106        assert!(snip.contains("chunks.append(chunk)"), "python: {snip}");
1107    }
1108
1109    #[test]
1110    fn accessor_stream_content_python_uses_join() {
1111        let expr = StreamingFieldResolver::accessor("stream_content", "python", "chunks").unwrap();
1112        assert!(expr.contains("\"\".join("), "python stream_content: {expr}");
1113        assert!(expr.contains("c.choices"), "python stream_content: {expr}");
1114    }
1115
1116    #[test]
1117    fn accessor_stream_complete_python_uses_finish_reason() {
1118        let expr = StreamingFieldResolver::accessor("stream_complete", "python", "chunks").unwrap();
1119        assert!(
1120            expr.contains("finish_reason is not None"),
1121            "python stream_complete: {expr}"
1122        );
1123    }
1124
1125    #[test]
1126    fn accessor_finish_reason_python_uses_last_chunk() {
1127        let expr = StreamingFieldResolver::accessor("finish_reason", "python", "chunks").unwrap();
1128        assert!(expr.contains("chunks[-1]"), "python finish_reason: {expr}");
1129        // Must wrap in str() so FinishReason enum objects support .strip() comparisons
1130        assert!(
1131            expr.starts_with("(str(") || expr.contains("str(chunks"),
1132            "python finish_reason must wrap in str(): {expr}"
1133        );
1134    }
1135
1136    #[test]
1137    fn accessor_tool_calls_python_uses_list_comprehension() {
1138        let expr = StreamingFieldResolver::accessor("tool_calls", "python", "chunks").unwrap();
1139        assert!(expr.contains("for c in chunks"), "python tool_calls: {expr}");
1140        assert!(expr.contains("tool_calls"), "python tool_calls: {expr}");
1141    }
1142
1143    #[test]
1144    fn accessor_usage_python_uses_last_chunk() {
1145        let expr = StreamingFieldResolver::accessor("usage", "python", "chunks").unwrap();
1146        assert!(
1147            expr.contains("chunks[-1].usage"),
1148            "python usage: expected chunks[-1].usage, got: {expr}"
1149        );
1150    }
1151
1152    #[test]
1153    fn accessor_usage_total_tokens_does_not_route_via_chunks() {
1154        // `usage` is intentionally NOT a streaming-virtual root (it overlaps the
1155        // non-streaming response shape). The accessor must return None so the
1156        // assertion falls through to the normal field-path codegen.
1157        assert!(StreamingFieldResolver::accessor("usage.total_tokens", "python", "chunks").is_none());
1158    }
1159
1160    #[test]
1161    fn accessor_unknown_field_returns_none() {
1162        assert_eq!(
1163            StreamingFieldResolver::accessor("nonexistent_field", "rust", "chunks"),
1164            None
1165        );
1166    }
1167
1168    // -----------------------------------------------------------------------
1169    // Deep-path tests: tool_calls[0].function.name and tool_calls[0].id
1170    // -----------------------------------------------------------------------
1171
1172    #[test]
1173    fn is_streaming_virtual_field_recognizes_deep_tool_calls_paths() {
1174        assert!(
1175            is_streaming_virtual_field("tool_calls[0].function.name"),
1176            "tool_calls[0].function.name should be recognized"
1177        );
1178        assert!(
1179            is_streaming_virtual_field("tool_calls[0].id"),
1180            "tool_calls[0].id should be recognized"
1181        );
1182        assert!(
1183            is_streaming_virtual_field("tool_calls[1].function.arguments"),
1184            "tool_calls[1].function.arguments should be recognized"
1185        );
1186        // bare root still recognized
1187        assert!(is_streaming_virtual_field("tool_calls"));
1188        // unrelated deep path must NOT be recognized
1189        assert!(!is_streaming_virtual_field("tool_calls_extra.name"));
1190        assert!(!is_streaming_virtual_field("nonexistent[0].field"));
1191    }
1192
1193    /// Snapshot: `tool_calls[0].function.name` for Rust, Kotlin, TypeScript.
1194    ///
1195    /// These three languages cover the main accessor styles:
1196    /// - Rust: snake_case field, explicit `[0]` index on collected Vec
1197    /// - Kotlin: camelCase method calls with `.first()` for index 0
1198    /// - TypeScript/Node: camelCase properties with `[0]` bracket
1199    #[test]
1200    fn deep_tool_calls_function_name_snapshot_rust_kotlin_ts() {
1201        let field = "tool_calls[0].function.name";
1202
1203        let rust = StreamingFieldResolver::accessor(field, "rust", "chunks").unwrap();
1204        // Rust: Option-aware chain over the iterator — `.nth(0)` then `.and_then`
1205        // on each Option-wrapped field (function is Option<StreamFunctionCall>,
1206        // name is Option<String>). Final `.unwrap_or("")` yields `&str`.
1207        assert!(
1208            rust.contains(".nth(0)"),
1209            "rust deep tool_calls: expected .nth(0) iterator index, got: {rust}"
1210        );
1211        assert!(
1212            rust.contains("x.function.as_ref()"),
1213            "rust deep tool_calls: expected Option-aware function access, got: {rust}"
1214        );
1215        assert!(
1216            rust.contains("x.name.as_deref()"),
1217            "rust deep tool_calls: expected Option-aware name leaf, got: {rust}"
1218        );
1219        assert!(
1220            !rust.contains("// skipped"),
1221            "rust deep tool_calls: must not emit skip comment, got: {rust}"
1222        );
1223
1224        let kotlin = StreamingFieldResolver::accessor(field, "kotlin", "chunks").unwrap();
1225        // Kotlin: uses .first() for index 0, then .function().name()
1226        assert!(
1227            kotlin.contains(".first()"),
1228            "kotlin deep tool_calls: expected .first() for index 0, got: {kotlin}"
1229        );
1230        assert!(
1231            kotlin.contains(".function()"),
1232            "kotlin deep tool_calls: expected .function() method call, got: {kotlin}"
1233        );
1234        assert!(
1235            kotlin.contains(".name()"),
1236            "kotlin deep tool_calls: expected .name() method call, got: {kotlin}"
1237        );
1238
1239        let ts = StreamingFieldResolver::accessor(field, "node", "chunks").unwrap();
1240        // TypeScript/Node: uses [0] then .function.name (camelCase)
1241        assert!(
1242            ts.contains("[0]"),
1243            "ts/node deep tool_calls: expected [0] index, got: {ts}"
1244        );
1245        assert!(
1246            ts.contains(".function"),
1247            "ts/node deep tool_calls: expected .function segment, got: {ts}"
1248        );
1249        assert!(
1250            ts.contains(".name"),
1251            "ts/node deep tool_calls: expected .name segment, got: {ts}"
1252        );
1253    }
1254
1255    #[test]
1256    fn deep_tool_calls_id_snapshot_all_langs() {
1257        let field = "tool_calls[0].id";
1258
1259        let rust = StreamingFieldResolver::accessor(field, "rust", "chunks").unwrap();
1260        assert!(rust.contains(".nth(0)"), "rust: {rust}");
1261        assert!(rust.contains("x.id.as_deref()"), "rust: {rust}");
1262
1263        let go = StreamingFieldResolver::accessor(field, "go", "chunks").unwrap();
1264        assert!(go.contains("[0]"), "go: {go}");
1265        // Go: ID is a well-known initialism → uppercase
1266        assert!(go.contains(".ID"), "go: expected .ID initialism, got: {go}");
1267
1268        let python = StreamingFieldResolver::accessor(field, "python", "chunks").unwrap();
1269        assert!(python.contains("[0]"), "python: {python}");
1270        assert!(python.contains(".id"), "python: {python}");
1271
1272        let php = StreamingFieldResolver::accessor(field, "php", "chunks").unwrap();
1273        assert!(php.contains("[0]"), "php: {php}");
1274        assert!(php.contains("->id"), "php: expected ->id, got: {php}");
1275
1276        let java = StreamingFieldResolver::accessor(field, "java", "chunks").unwrap();
1277        assert!(java.contains(".get(0)"), "java: expected .get(0), got: {java}");
1278        assert!(java.contains(".id()"), "java: expected .id() method call, got: {java}");
1279
1280        let csharp = StreamingFieldResolver::accessor(field, "csharp", "chunks").unwrap();
1281        assert!(csharp.contains("[0]"), "csharp: {csharp}");
1282        assert!(
1283            csharp.contains(".Id"),
1284            "csharp: expected .Id (PascalCase), got: {csharp}"
1285        );
1286
1287        let elixir = StreamingFieldResolver::accessor(field, "elixir", "chunks").unwrap();
1288        assert!(elixir.contains("Enum.at("), "elixir: expected Enum.at(, got: {elixir}");
1289        assert!(elixir.contains(".id"), "elixir: {elixir}");
1290    }
1291
1292    #[test]
1293    fn deep_tool_calls_function_name_snapshot_python_elixir_zig() {
1294        let field = "tool_calls[0].function.name";
1295
1296        let python = StreamingFieldResolver::accessor(field, "python", "chunks").unwrap();
1297        assert!(python.contains("[0]"), "python: {python}");
1298        assert!(python.contains(".function"), "python: {python}");
1299        assert!(python.contains(".name"), "python: {python}");
1300
1301        let elixir = StreamingFieldResolver::accessor(field, "elixir", "chunks").unwrap();
1302        // Elixir: Enum.at(…, 0).function.name
1303        assert!(elixir.contains("Enum.at("), "elixir: {elixir}");
1304        assert!(elixir.contains(".function"), "elixir: {elixir}");
1305        assert!(elixir.contains(".name"), "elixir: {elixir}");
1306
1307        // Zig stores chunks as JSON strings, not typed records — deep
1308        // tool_calls paths are unsupported and resolve to None so the
1309        // assertion site can skip them.
1310        assert!(
1311            StreamingFieldResolver::accessor(field, "zig", "chunks").is_none(),
1312            "zig: expected None for deep tool_calls path"
1313        );
1314    }
1315
1316    #[test]
1317    fn parse_tail_parses_index_then_field_segments() {
1318        let segs = parse_tail("[0].function.name");
1319        assert_eq!(segs.len(), 3, "expected 3 segments, got: {segs:?}");
1320        assert_eq!(segs[0], TailSeg::Index(0));
1321        assert_eq!(segs[1], TailSeg::Field("function".to_string()));
1322        assert_eq!(segs[2], TailSeg::Field("name".to_string()));
1323    }
1324
1325    #[test]
1326    fn parse_tail_parses_simple_index_field() {
1327        let segs = parse_tail("[0].id");
1328        assert_eq!(segs.len(), 2, "expected 2 segments, got: {segs:?}");
1329        assert_eq!(segs[0], TailSeg::Index(0));
1330        assert_eq!(segs[1], TailSeg::Field("id".to_string()));
1331    }
1332
1333    #[test]
1334    fn parse_tail_handles_nonzero_index() {
1335        let segs = parse_tail("[2].function.arguments");
1336        assert_eq!(segs[0], TailSeg::Index(2));
1337        assert_eq!(segs[1], TailSeg::Field("function".to_string()));
1338        assert_eq!(segs[2], TailSeg::Field("arguments".to_string()));
1339    }
1340
1341    // -----------------------------------------------------------------------
1342    // Swift-specific accessor tests
1343    // -----------------------------------------------------------------------
1344
1345    #[test]
1346    fn accessor_chunks_length_swift_uses_count() {
1347        let swift = StreamingFieldResolver::accessor("chunks.length", "swift", "chunks").unwrap();
1348        assert_eq!(swift, "chunks.count", "swift chunks.length: {swift}");
1349    }
1350
1351    #[test]
1352    fn accessor_stream_content_swift_uses_swift_closures() {
1353        let expr = StreamingFieldResolver::accessor("stream_content", "swift", "chunks").unwrap();
1354        // Must use Swift closure syntax (`{ ... }`) not JS arrow (`=>`)
1355        assert!(
1356            expr.contains("{ c in"),
1357            "swift stream_content must use Swift closure syntax, got: {expr}"
1358        );
1359        assert!(
1360            !expr.contains("=>"),
1361            "swift stream_content must not contain JS arrow `=>`, got: {expr}"
1362        );
1363        // Content is accessed via method call chains
1364        assert!(
1365            expr.contains("choices()"),
1366            "swift stream_content must use .choices() method call, got: {expr}"
1367        );
1368        assert!(
1369            expr.contains("delta()"),
1370            "swift stream_content must use .delta() method call, got: {expr}"
1371        );
1372        assert!(
1373            expr.contains("content()"),
1374            "swift stream_content must use .content() method call, got: {expr}"
1375        );
1376        assert!(
1377            expr.contains(".toString()"),
1378            "swift stream_content must convert RustString via .toString(), got: {expr}"
1379        );
1380        assert!(
1381            expr.contains(".joined()"),
1382            "swift stream_content must join with .joined(), got: {expr}"
1383        );
1384        // Must not use JS .length or .join('')
1385        assert!(
1386            !expr.contains(".length"),
1387            "swift stream_content must not use JS .length, got: {expr}"
1388        );
1389        assert!(
1390            !expr.contains(".join("),
1391            "swift stream_content must not use JS .join(, got: {expr}"
1392        );
1393    }
1394
1395    #[test]
1396    fn accessor_stream_complete_swift_uses_swift_syntax() {
1397        let expr = StreamingFieldResolver::accessor("stream_complete", "swift", "chunks").unwrap();
1398        // Must use Swift isEmpty / last! syntax, not JS .length
1399        assert!(
1400            expr.contains("isEmpty"),
1401            "swift stream_complete must use .isEmpty, got: {expr}"
1402        );
1403        assert!(
1404            expr.contains(".last!"),
1405            "swift stream_complete must use .last!, got: {expr}"
1406        );
1407        assert!(
1408            expr.contains("choices()"),
1409            "swift stream_complete must use .choices() method call, got: {expr}"
1410        );
1411        assert!(
1412            expr.contains("finish_reason()"),
1413            "swift stream_complete must use .finish_reason(), got: {expr}"
1414        );
1415        assert!(
1416            !expr.contains(".length"),
1417            "swift stream_complete must not use JS .length, got: {expr}"
1418        );
1419        assert!(
1420            !expr.contains("!= null"),
1421            "swift stream_complete must not use JS `!= null`, got: {expr}"
1422        );
1423    }
1424
1425    #[test]
1426    fn accessor_tool_calls_swift_uses_swift_flatmap() {
1427        let expr = StreamingFieldResolver::accessor("tool_calls", "swift", "chunks").unwrap();
1428        // Must use Swift closure syntax, not JS arrow
1429        assert!(
1430            !expr.contains("=>"),
1431            "swift tool_calls must not contain JS arrow `=>`, got: {expr}"
1432        );
1433        assert!(
1434            expr.contains("flatMap"),
1435            "swift tool_calls must use flatMap, got: {expr}"
1436        );
1437        assert!(
1438            expr.contains("choices()"),
1439            "swift tool_calls must use .choices() method call, got: {expr}"
1440        );
1441        assert!(
1442            expr.contains("delta()"),
1443            "swift tool_calls must use .delta() method call, got: {expr}"
1444        );
1445        assert!(
1446            expr.contains("tool_calls()"),
1447            "swift tool_calls must use .tool_calls() method call, got: {expr}"
1448        );
1449    }
1450
1451    #[test]
1452    fn accessor_tool_calls_deep_path_swift_uses_method_calls_with_optional_chain() {
1453        // `tool_calls[0].function.name` must resolve to Swift method calls with
1454        // optional chaining because swift-bridge opaque refs expose fields as
1455        // methods that return Optional.
1456        let expr = StreamingFieldResolver::accessor("tool_calls[0].function.name", "swift", "chunks").unwrap();
1457        assert!(
1458            expr.contains("function()"),
1459            "swift deep tool_calls must use .function() method call, got: {expr}"
1460        );
1461        assert!(
1462            expr.contains("name()"),
1463            "swift deep tool_calls must use .name() method call, got: {expr}"
1464        );
1465        assert!(
1466            expr.contains(".toString()"),
1467            "swift deep tool_calls must convert RustString via .toString(), got: {expr}"
1468        );
1469        assert!(
1470            !expr.contains("=>"),
1471            "swift deep tool_calls must not use JS arrow syntax, got: {expr}"
1472        );
1473    }
1474
1475    #[test]
1476    fn accessor_finish_reason_swift_uses_swift_syntax() {
1477        let expr = StreamingFieldResolver::accessor("finish_reason", "swift", "chunks").unwrap();
1478        // Must use Swift isEmpty / last! syntax, not JS .length / undefined
1479        assert!(
1480            expr.contains("isEmpty"),
1481            "swift finish_reason must use .isEmpty, got: {expr}"
1482        );
1483        assert!(
1484            expr.contains(".last!"),
1485            "swift finish_reason must use .last!, got: {expr}"
1486        );
1487        assert!(
1488            expr.contains("finish_reason()"),
1489            "swift finish_reason must use .finish_reason() method call, got: {expr}"
1490        );
1491        assert!(
1492            expr.contains(".toString()"),
1493            "swift finish_reason must convert RustString via .toString(), got: {expr}"
1494        );
1495        assert!(
1496            !expr.contains("undefined"),
1497            "swift finish_reason must not use JS `undefined`, got: {expr}"
1498        );
1499        assert!(
1500            !expr.contains(".length"),
1501            "swift finish_reason must not use JS .length, got: {expr}"
1502        );
1503    }
1504
1505    #[test]
1506    fn accessor_usage_swift_uses_swift_syntax() {
1507        let expr = StreamingFieldResolver::accessor("usage", "swift", "chunks").unwrap();
1508        // Must use Swift isEmpty / last! syntax, not JS .length / undefined
1509        assert!(expr.contains("isEmpty"), "swift usage must use .isEmpty, got: {expr}");
1510        assert!(expr.contains(".last!"), "swift usage must use .last!, got: {expr}");
1511        assert!(
1512            expr.contains("usage()"),
1513            "swift usage must use .usage() method call, got: {expr}"
1514        );
1515        assert!(
1516            !expr.contains("undefined"),
1517            "swift usage must not use JS `undefined`, got: {expr}"
1518        );
1519        assert!(
1520            !expr.contains(".length"),
1521            "swift usage must not use JS .length, got: {expr}"
1522        );
1523    }
1524
1525    // ---------------------------------------------------------------------------
1526    // Bug regression: kotlin_android streaming assertions use property access
1527    // ---------------------------------------------------------------------------
1528
1529    #[test]
1530    fn kotlin_android_collect_snippet_uses_flow_to_list() {
1531        let snip = StreamingFieldResolver::collect_snippet("kotlin_android", "result", "chunks").unwrap();
1532        // Flow.toList() — not Iterator.asSequence().toList()
1533        assert!(
1534            snip.contains("result.toList()"),
1535            "kotlin_android collect must use Flow.toList(), got: {snip}"
1536        );
1537        assert!(
1538            !snip.contains("asSequence()"),
1539            "kotlin_android collect must NOT use asSequence(), got: {snip}"
1540        );
1541    }
1542
1543    #[test]
1544    fn kotlin_android_stream_content_uses_property_access() {
1545        let expr = StreamingFieldResolver::accessor("stream_content", "kotlin_android", "chunks").unwrap();
1546        assert!(
1547            expr.contains(".choices"),
1548            "kotlin_android stream_content must use .choices property, got: {expr}"
1549        );
1550        assert!(
1551            !expr.contains(".choices()"),
1552            "kotlin_android stream_content must NOT use .choices() getter, got: {expr}"
1553        );
1554        assert!(
1555            expr.contains(".delta"),
1556            "kotlin_android stream_content must use .delta property, got: {expr}"
1557        );
1558        assert!(
1559            !expr.contains(".delta()"),
1560            "kotlin_android stream_content must NOT use .delta() getter, got: {expr}"
1561        );
1562        assert!(
1563            expr.contains(".content"),
1564            "kotlin_android stream_content must use .content property, got: {expr}"
1565        );
1566        assert!(
1567            !expr.contains(".content()"),
1568            "kotlin_android stream_content must NOT use .content() getter, got: {expr}"
1569        );
1570    }
1571
1572    #[test]
1573    fn kotlin_android_finish_reason_uses_name_lowercase_not_get_value() {
1574        let expr = StreamingFieldResolver::accessor("finish_reason", "kotlin_android", "chunks").unwrap();
1575        assert!(
1576            expr.contains(".finishReason"),
1577            "kotlin_android finish_reason must use .finishReason property, got: {expr}"
1578        );
1579        assert!(
1580            !expr.contains(".finishReason()"),
1581            "kotlin_android finish_reason must NOT use .finishReason() getter, got: {expr}"
1582        );
1583        assert!(
1584            expr.contains(".name"),
1585            "kotlin_android finish_reason must use .name for enum wire value, got: {expr}"
1586        );
1587        assert!(
1588            expr.contains(".lowercase()"),
1589            "kotlin_android finish_reason must use .lowercase(), got: {expr}"
1590        );
1591        assert!(
1592            !expr.contains(".getValue()"),
1593            "kotlin_android finish_reason must NOT use .getValue(), got: {expr}"
1594        );
1595    }
1596
1597    #[test]
1598    fn kotlin_android_usage_uses_property_access() {
1599        let expr = StreamingFieldResolver::accessor("usage", "kotlin_android", "chunks").unwrap();
1600        assert!(
1601            expr.contains(".usage"),
1602            "kotlin_android usage must use .usage property, got: {expr}"
1603        );
1604        assert!(
1605            !expr.contains(".usage()"),
1606            "kotlin_android usage must NOT use .usage() getter, got: {expr}"
1607        );
1608    }
1609
1610    #[test]
1611    fn kotlin_android_deep_tool_calls_uses_property_access() {
1612        let expr = StreamingFieldResolver::accessor("tool_calls[0].function.name", "kotlin_android", "chunks").unwrap();
1613        assert!(
1614            expr.contains(".function"),
1615            "kotlin_android deep tool_calls must use .function property, got: {expr}"
1616        );
1617        assert!(
1618            !expr.contains(".function()"),
1619            "kotlin_android deep tool_calls must NOT use .function() getter, got: {expr}"
1620        );
1621        assert!(
1622            expr.contains(".name"),
1623            "kotlin_android deep tool_calls must use .name property, got: {expr}"
1624        );
1625        assert!(
1626            !expr.contains(".name()"),
1627            "kotlin_android deep tool_calls must NOT use .name() getter, got: {expr}"
1628        );
1629    }
1630}