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.
48const STREAMING_VIRTUAL_ROOTS: &[&str] = &["tool_calls", "finish_reason"];
49
50/// Returns `true` when `field` is a streaming-virtual field name, including
51/// deep-nested paths that start with a known streaming-virtual root.
52///
53/// Examples that return `true`:
54/// - `"tool_calls"` (exact root)
55/// - `"tool_calls[0].function.name"` (deep path)
56/// - `"tool_calls[0].id"` (deep path)
57pub fn is_streaming_virtual_field(field: &str) -> bool {
58    if STREAMING_VIRTUAL_FIELDS.contains(&field) {
59        return true;
60    }
61    // Check deep-path prefixes: `tool_calls[…` or `tool_calls.`
62    for root in STREAMING_VIRTUAL_ROOTS {
63        if field.len() > root.len() && field.starts_with(root) {
64            let rest = &field[root.len()..];
65            if rest.starts_with('[') || rest.starts_with('.') {
66                return true;
67            }
68        }
69    }
70    false
71}
72
73/// Split a field path into `(root, tail)` when it starts with a streaming-virtual
74/// root and has a continuation.
75///
76/// Returns `None` when the field is an exact root match (no tail) or is not a
77/// streaming-virtual root at all.
78fn split_streaming_deep_path(field: &str) -> Option<(&str, &str)> {
79    for root in STREAMING_VIRTUAL_ROOTS {
80        if field.len() > root.len() && field.starts_with(root) {
81            let rest = &field[root.len()..];
82            if rest.starts_with('[') || rest.starts_with('.') {
83                return Some((root, rest));
84            }
85        }
86    }
87    None
88}
89
90/// Shared streaming-virtual-fields resolver for e2e test codegen.
91pub struct StreamingFieldResolver;
92
93impl StreamingFieldResolver {
94    /// Returns the language-specific expression for a streaming-virtual field,
95    /// given `chunks_var` (the collected-list local name) and `lang`.
96    ///
97    /// Returns `None` when the field name is not a known streaming-virtual
98    /// field or the language has no streaming support.
99    pub fn accessor(field: &str, lang: &str, chunks_var: &str) -> Option<String> {
100        match field {
101            "chunks" => Some(match lang {
102                // Zig ArrayList does not expose .len directly; must use .items
103                "zig" => format!("{chunks_var}.items"),
104                // PHP variables require `$` sigil — bareword `chunks` is parsed as a
105                // constant reference and triggers "Undefined constant" errors.
106                "php" => format!("${chunks_var}"),
107                _ => chunks_var.to_string(),
108            }),
109
110            "chunks.length" => Some(match lang {
111                "rust" => format!("{chunks_var}.len()"),
112                "go" => format!("len({chunks_var})"),
113                "python" => format!("len({chunks_var})"),
114                "php" => format!("count(${chunks_var})"),
115                "elixir" => format!("length({chunks_var})"),
116                // kotlin List.size is a property (not .length)
117                "kotlin" => format!("{chunks_var}.size"),
118                // zig: chunks_var is ArrayList([]u8); use .items.len
119                "zig" => format!("{chunks_var}.items.len"),
120                // node/wasm/typescript use .length
121                _ => format!("{chunks_var}.length"),
122            }),
123
124            "stream_content" => Some(match lang {
125                "rust" => {
126                    format!(
127                        "{chunks_var}.iter().map(|c| c.choices.first().and_then(|ch| ch.delta.content.as_deref()).unwrap_or(\"\")).collect::<String>()"
128                    )
129                }
130                "go" => {
131                    // Go: chunks is []pkg.ChatCompletionChunk
132                    format!(
133                        "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 }}()"
134                    )
135                }
136                "java" => {
137                    format!(
138                        "{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())"
139                    )
140                }
141                "php" => {
142                    format!("implode('', array_map(fn($c) => $c->choices[0]->delta->content ?? '', ${chunks_var}))")
143                }
144                "kotlin" => {
145                    // Kotlin: chunks is List<ChatCompletionChunk> (Java records via typealias).
146                    // choices() / delta() / content() are Java record accessor methods.
147                    format!(
148                        "{chunks_var}.joinToString(\"\") {{ it.choices()?.firstOrNull()?.delta()?.content() ?: \"\" }}"
149                    )
150                }
151                "elixir" => {
152                    format!("{chunks_var} |> Enum.map(&(&1.choices[0].delta.content || \"\")) |> Enum.join(\"\")")
153                }
154                "python" => {
155                    format!("\"\".join(c.choices[0].delta.content or \"\" for c in {chunks_var} if c.choices)")
156                }
157                "zig" => {
158                    // Zig: `{chunks_var}_content` is a `std.ArrayList(u8)` populated by
159                    // the collect snippet. `.items` gives a `[]u8` slice of the content.
160                    format!("{chunks_var}_content.items")
161                }
162                // node/wasm/typescript
163                _ => {
164                    format!("{chunks_var}.map((c: any) => c.choices?.[0]?.delta?.content ?? '').join('')")
165                }
166            }),
167
168            "stream_complete" => Some(match lang {
169                "rust" => {
170                    format!(
171                        "{chunks_var}.last().and_then(|c| c.choices.first()).and_then(|ch| ch.finish_reason.as_ref()).is_some()"
172                    )
173                }
174                "go" => {
175                    format!(
176                        "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 }}()"
177                    )
178                }
179                "java" => {
180                    format!(
181                        "!{chunks_var}.isEmpty() && {chunks_var}.get({chunks_var}.size()-1).choices().stream().findFirst().flatMap(ch -> java.util.Optional.ofNullable(ch.finishReason())).isPresent()"
182                    )
183                }
184                "php" => {
185                    // PHP streaming chunks come from `json_decode` of the binding's JSON
186                    // string return, so property names follow the JSON wire format
187                    // (snake_case `finish_reason`, not camelCase `finishReason`).
188                    format!("!empty(${chunks_var}) && isset(end(${chunks_var})->choices[0]->finish_reason)")
189                }
190                "kotlin" => {
191                    // Kotlin: use isNotEmpty() + last() + safe-call chain
192                    format!(
193                        "{chunks_var}.isNotEmpty() && {chunks_var}.last().choices()?.firstOrNull()?.finishReason() != null"
194                    )
195                }
196                "python" => {
197                    format!("bool({chunks_var}) and {chunks_var}[-1].choices[0].finish_reason is not None")
198                }
199                "elixir" => {
200                    format!("List.last({chunks_var}).choices[0].finish_reason != nil")
201                }
202                // zig: the collect snippet exhausts the stream; check last chunk JSON
203                // was collected (chunks.items is non-empty) as a proxy for completion.
204                "zig" => {
205                    format!("{chunks_var}.items.len > 0")
206                }
207                // node/wasm/typescript
208                _ => {
209                    format!(
210                        "{chunks_var}.length > 0 && {chunks_var}[{chunks_var}.length - 1].choices?.[0]?.finishReason != null"
211                    )
212                }
213            }),
214
215            // no_chunks_after_done is a structural invariant: once the stream
216            // closes (channel drained / iterator exhausted), no further chunks
217            // can arrive.  We assert `true` as a compile-time proof of intent.
218            "no_chunks_after_done" => Some(match lang {
219                "rust" => "true".to_string(),
220                "go" => "true".to_string(),
221                "java" => "true".to_string(),
222                "php" => "true".to_string(),
223                _ => "true".to_string(),
224            }),
225
226            "tool_calls" => Some(match lang {
227                "rust" => {
228                    format!(
229                        "{chunks_var}.iter().flat_map(|c| c.choices.iter().flat_map(|ch| ch.delta.tool_calls.iter().flatten())).collect::<Vec<_>>()"
230                    )
231                }
232                "go" => {
233                    // StreamDelta.ToolCalls is `[]StreamToolCall` (slice, not pointer).
234                    // Return the typed slice so deep-path accessors like `tool_calls[0].function.name`
235                    // can index and access typed fields.
236                    format!(
237                        "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 }}()"
238                    )
239                }
240                "java" => {
241                    format!(
242                        "{chunks_var}.stream().flatMap(c -> c.choices().stream()).flatMap(ch -> ch.delta().toolCalls() != null ? ch.delta().toolCalls().stream() : java.util.stream.Stream.empty()).toList()"
243                    )
244                }
245                "php" => {
246                    // PHP streaming chunks are json_decoded stdClass — use snake_case
247                    // `tool_calls` to match the JSON wire format.
248                    format!(
249                        "array_merge(...array_map(fn($c) => $c->choices[0]->delta->tool_calls ?? [], ${chunks_var}))"
250                    )
251                }
252                "kotlin" => {
253                    // Kotlin: flatten tool_calls from all chunk deltas into one list
254                    format!(
255                        "{chunks_var}.flatMap {{ c -> c.choices()?.flatMap {{ ch -> ch.delta()?.toolCalls() ?: emptyList() }} ?: emptyList() }}"
256                    )
257                }
258                "python" => {
259                    format!(
260                        "[t for c in {chunks_var} for ch in (c.choices or []) for t in (ch.delta.tool_calls or [])]"
261                    )
262                }
263                "elixir" => {
264                    format!(
265                        "{chunks_var} |> Enum.flat_map(fn c -> (List.first(c.choices) || %{{}}).delta |> Map.get(:tool_calls, []) end)"
266                    )
267                }
268                // Zig: tool_calls count from all chunk deltas
269                "zig" => {
270                    format!("{chunks_var}.items")
271                }
272                _ => {
273                    format!("{chunks_var}.flatMap((c: any) => c.choices?.[0]?.delta?.toolCalls ?? [])")
274                }
275            }),
276
277            "finish_reason" => Some(match lang {
278                "rust" => {
279                    // ChatCompletionChunk's finish_reason is Option<FinishReason> (enum, not
280                    // String). Display impl writes the JSON wire form (e.g. "tool_calls").
281                    format!(
282                        "{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()"
283                    )
284                }
285                "go" => {
286                    // FinishReason is a typed alias (`type FinishReason string`) in bindings,
287                    // so cast to string explicitly to match the assertion target type.
288                    format!(
289                        "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 \"\" }}()"
290                    )
291                }
292                "java" => {
293                    format!(
294                        "({chunks_var}.isEmpty() ? null : {chunks_var}.get({chunks_var}.size()-1).choices().stream().findFirst().map(ch -> ch.finishReason()).orElse(null))"
295                    )
296                }
297                "php" => {
298                    // PHP streaming chunks are json_decoded stdClass — use snake_case
299                    // `finish_reason` to match the JSON wire format.
300                    format!("(!empty(${chunks_var}) ? (end(${chunks_var})->choices[0]->finish_reason ?? null) : null)")
301                }
302                "kotlin" => {
303                    // Returns the string value of the finish_reason enum from the last chunk.
304                    // FinishReason.getValue() returns the JSON wire string (e.g. "tool_calls").
305                    format!(
306                        "(if ({chunks_var}.isEmpty()) null else {chunks_var}.last().choices()?.firstOrNull()?.finishReason()?.getValue())"
307                    )
308                }
309                "python" => {
310                    format!(
311                        "({chunks_var}[-1].choices[0].finish_reason if {chunks_var} and {chunks_var}[-1].choices else None)"
312                    )
313                }
314                "elixir" => {
315                    format!("List.last({chunks_var}).choices[0].finish_reason")
316                }
317                // Zig: finish_reason from the last chunk's JSON via an inline labeled block.
318                // Returns `[]const u8` (unwrapped with orelse "" for expectEqualStrings).
319                "zig" => {
320                    format!(
321                        "(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 \"\"; }})"
322                    )
323                }
324                _ => {
325                    format!(
326                        "{chunks_var}.length > 0 ? {chunks_var}[{chunks_var}.length - 1].choices?.[0]?.finishReason : undefined"
327                    )
328                }
329            }),
330
331            _ => {
332                // Deep-path: e.g. `tool_calls[0].function.name`
333                // Split into root + tail, get the root's inline expression, then
334                // render the tail (index + fields) in a per-language style on top.
335                if let Some((root, tail)) = split_streaming_deep_path(field) {
336                    // Rust needs Option-aware chaining for the StreamToolCall fields
337                    // (function/id are Option<...>). The generic tail renderer can't
338                    // infer Option-wrapping, so we emit rust-specific idiom here.
339                    if lang == "rust" && root == "tool_calls" {
340                        return Some(render_rust_tool_calls_deep(chunks_var, tail));
341                    }
342                    // Zig stores stream chunks as JSON strings (`[]const u8`) in
343                    // `chunks: ArrayList([]u8)`, not typed `ChatCompletionChunk`
344                    // records. A deep `tool_calls[N].function.name` access would
345                    // require parsing each chunk's JSON inline — rather than
346                    // emit code that won't compile, signal "unsupported" so the
347                    // assertion is skipped at the call site.
348                    if lang == "zig" && root == "tool_calls" {
349                        return None;
350                    }
351                    let root_expr = Self::accessor(root, lang, chunks_var)?;
352                    Some(render_deep_tail(&root_expr, tail, lang))
353                } else {
354                    None
355                }
356            }
357        }
358    }
359
360    /// Returns the language-specific stream-collect-into-list snippet that
361    /// produces `chunks_var` from `stream_var`.
362    ///
363    /// Returns `None` when the language has no streaming collect support or
364    /// when the collect snippet cannot be expressed generically.
365    pub fn collect_snippet(lang: &str, stream_var: &str, chunks_var: &str) -> Option<String> {
366        match lang {
367            "rust" => Some(format!(
368                "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();"
369            )),
370            "go" => Some(format!(
371                "var {chunks_var} []pkg.ChatCompletionChunk\n\tfor chunk := range {stream_var} {{\n\t\t{chunks_var} = append({chunks_var}, chunk)\n\t}}"
372            )),
373            "java" => Some(format!(
374                "var {chunks_var} = new java.util.ArrayList<ChatCompletionChunk>();\n        var _it = {stream_var};\n        while (_it.hasNext()) {{ {chunks_var}.add(_it.next()); }}"
375            )),
376            // PHP binding's chat_stream_async typically returns a JSON string of the
377            // chunk array (PHP cannot expose Rust iterators directly via ext-php-rs).
378            // Decode to an array of stdClass objects so accessor chains like
379            // `$c->choices[0]->delta->content` resolve against the JSON wire shape
380            // (snake_case keys).  Falls back to iterator_to_array for a future binding
381            // upgrade that exposes a real iterator.
382            "php" => Some(format!(
383                "${chunks_var} = is_string(${stream_var}) ? (json_decode(${stream_var}) ?: []) : iterator_to_array(${stream_var});"
384            )),
385            "python" => Some(format!(
386                "{chunks_var} = []\n    async for chunk in {stream_var}:\n        {chunks_var}.append(chunk)"
387            )),
388            "kotlin" => {
389                // Kotlin: chatStream returns Iterator<ChatCompletionChunk> (from Java bridge).
390                // Drain into a Kotlin List using asSequence().toList().
391                Some(format!("val {chunks_var} = {stream_var}.asSequence().toList()"))
392            }
393            "elixir" => Some(format!("{chunks_var} = Enum.to_list({stream_var})")),
394            "node" | "wasm" | "typescript" => Some(format!(
395                "const {chunks_var}: any[] = [];\n    for await (const _chunk of {stream_var}) {{ {chunks_var}.push(_chunk); }}"
396            )),
397            "zig" => {
398                // Zig 0.16: ArrayList is unmanaged — no stored allocator.
399                // Use `.empty` to initialize, pass `std.heap.c_allocator` to each mutation.
400                // `stream_var` is the opaque stream handle obtained via `_start`.
401                // We collect every chunk's JSON string into `chunks_var: ArrayList([]u8)`
402                // and concatenate delta content into `{chunks_var}_content: ArrayList(u8)`.
403                // Accessors use `.items.len` and `{chunks_var}_content.items` on these lists.
404                Some(format!(
405                    concat!(
406                        "var {chunks_var}: std.ArrayList([]u8) = .empty;
407",
408                        "    defer {{
409",
410                        "        for ({chunks_var}.items) |_cj| std.heap.c_allocator.free(_cj);
411",
412                        "        {chunks_var}.deinit(std.heap.c_allocator);
413",
414                        "    }}
415",
416                        "    var {chunks_var}_content: std.ArrayList(u8) = .empty;
417",
418                        "    defer {chunks_var}_content.deinit(std.heap.c_allocator);
419",
420                        "    while (true) {{
421",
422                        "        const _nc = liter_llm.c.literllm_default_client_chat_stream_next({stream_var});
423",
424                        "        if (_nc == null) break;
425",
426                        "        const _np = liter_llm.c.literllm_chat_completion_chunk_to_json(_nc);
427",
428                        "        liter_llm.c.literllm_chat_completion_chunk_free(_nc);
429",
430                        "        if (_np == null) continue;
431",
432                        "        const _ns = std.mem.span(_np);
433",
434                        "        const _nj = try std.heap.c_allocator.dupe(u8, _ns);
435",
436                        "        liter_llm.c.literllm_free_string(_np);
437",
438                        "        if (std.json.parseFromSlice(std.json.Value, std.heap.c_allocator, _nj, .{{}})) |_cp| {{
439",
440                        "            defer _cp.deinit();
441",
442                        "            if (_cp.value.object.get(\"choices\")) |_chs|
443",
444                        "                if (_chs.array.items.len > 0)
445",
446                        "                    if (_chs.array.items[0].object.get(\"delta\")) |_dl|
447",
448                        "                        if (_dl.object.get(\"content\")) |_ct|
449",
450                        "                            if (_ct == .string) try {chunks_var}_content.appendSlice(std.heap.c_allocator, _ct.string);
451",
452                        "        }} else |_| {{}}
453",
454                        "        try {chunks_var}.append(std.heap.c_allocator, _nj);
455",
456                        "    }}"
457                    ),
458                    chunks_var = chunks_var,
459                    stream_var = stream_var,
460                ))
461            }
462            _ => None,
463        }
464    }
465}
466
467/// Render a rust deep accessor for `tool_calls[N]...` paths over the flattened
468/// stream-chunk tool_calls iterator. Handles Option-wrapped fields by chaining
469/// `as_ref().and_then(...)` so the final value is a `&str` (for name/id/arguments).
470fn render_rust_tool_calls_deep(chunks_var: &str, tail: &str) -> String {
471    let segs = parse_tail(tail);
472    // Locate index segment (rust uses .nth(n) on the iterator instead of [N] on a Vec)
473    let idx = segs.iter().find_map(|s| match s {
474        TailSeg::Index(n) => Some(*n),
475        _ => None,
476    });
477    let field_segs: Vec<&str> = segs
478        .iter()
479        .filter_map(|s| match s {
480            TailSeg::Field(f) => Some(f.as_str()),
481            _ => None,
482        })
483        .collect();
484
485    let base = format!(
486        "{chunks_var}.iter().flat_map(|c| c.choices.iter().flat_map(|ch| ch.delta.tool_calls.iter().flatten()))"
487    );
488    let with_nth = match idx {
489        Some(n) => format!("{base}.nth({n})"),
490        None => base,
491    };
492
493    // Chain Option-aware field access. Every field on StreamToolCall is Option<...>;
494    // the leaf (String fields) uses `.as_deref()` to project to `&str`.
495    let mut expr = with_nth;
496    for (i, f) in field_segs.iter().enumerate() {
497        let is_leaf = i == field_segs.len() - 1;
498        if is_leaf {
499            expr = format!("{expr}.and_then(|x| x.{f}.as_deref())");
500        } else {
501            expr = format!("{expr}.and_then(|x| x.{f}.as_ref())");
502        }
503    }
504    format!("{expr}.unwrap_or(\"\")")
505}
506
507/// Parse a deep-path tail (e.g. `[0].function.name`) into structured segments.
508///
509/// The tail always starts with either `[N]` (array index) or `.field`.
510/// Returns a list of segments: `TailSeg::Index(N)` or `TailSeg::Field(name)`.
511#[derive(Debug, PartialEq)]
512enum TailSeg {
513    Index(usize),
514    Field(String),
515}
516
517fn parse_tail(tail: &str) -> Vec<TailSeg> {
518    let mut segs = Vec::new();
519    let mut rest = tail;
520    while !rest.is_empty() {
521        if let Some(inner) = rest.strip_prefix('[') {
522            // Array index: `[N]`
523            if let Some(close) = inner.find(']') {
524                let idx_str = &inner[..close];
525                if let Ok(idx) = idx_str.parse::<usize>() {
526                    segs.push(TailSeg::Index(idx));
527                }
528                rest = &inner[close + 1..];
529            } else {
530                break;
531            }
532        } else if let Some(inner) = rest.strip_prefix('.') {
533            // Field name: up to next `.` or `[`
534            let end = inner.find(['.', '[']).unwrap_or(inner.len());
535            segs.push(TailSeg::Field(inner[..end].to_string()));
536            rest = &inner[end..];
537        } else {
538            break;
539        }
540    }
541    segs
542}
543
544/// Render the full deep accessor expression by appending per-language tail
545/// segments onto `root_expr`.
546fn render_deep_tail(root_expr: &str, tail: &str, lang: &str) -> String {
547    use heck::{ToLowerCamelCase, ToPascalCase};
548
549    let segs = parse_tail(tail);
550    let mut out = root_expr.to_string();
551
552    for seg in &segs {
553        match (seg, lang) {
554            (TailSeg::Index(n), "rust") => {
555                out = format!("({out})[{n}]");
556            }
557            (TailSeg::Index(n), "java") => {
558                out = format!("({out}).get({n})");
559            }
560            (TailSeg::Index(n), "kotlin") => {
561                if *n == 0 {
562                    out = format!("({out}).first()");
563                } else {
564                    out = format!("({out}).get({n})");
565                }
566            }
567            (TailSeg::Index(n), "elixir") => {
568                out = format!("Enum.at({out}, {n})");
569            }
570            (TailSeg::Index(n), "zig") => {
571                out = format!("({out}).items[{n}]");
572            }
573            (TailSeg::Index(n), "php") => {
574                out = format!("({out})[{n}]");
575            }
576            (TailSeg::Index(n), _) => {
577                // rust-like for go (but we handle Field differently), python, node, ts, kotlin, etc.
578                out = format!("({out})[{n}]");
579            }
580            (TailSeg::Field(f), "rust") => {
581                use heck::ToSnakeCase;
582                out.push('.');
583                out.push_str(&f.to_snake_case());
584            }
585            (TailSeg::Field(f), "go") => {
586                use alef_codegen::naming::to_go_name;
587                out.push('.');
588                out.push_str(&to_go_name(f));
589            }
590            (TailSeg::Field(f), "java") => {
591                out.push('.');
592                out.push_str(&f.to_lower_camel_case());
593                out.push_str("()");
594            }
595            (TailSeg::Field(f), "kotlin") => {
596                out.push('.');
597                out.push_str(&f.to_lower_camel_case());
598                out.push_str("()");
599            }
600            (TailSeg::Field(f), "csharp") => {
601                out.push('.');
602                out.push_str(&f.to_pascal_case());
603            }
604            (TailSeg::Field(f), "php") => {
605                // Streaming PHP accessors operate on json_decoded stdClass with
606                // snake_case property names (JSON wire format), not the camelCase
607                // properties exposed on the PHP wrapper class. Use the raw field
608                // name verbatim.
609                out.push_str("->");
610                out.push_str(f);
611            }
612            (TailSeg::Field(f), "elixir") => {
613                out.push('.');
614                out.push_str(f);
615            }
616            (TailSeg::Field(f), "zig") => {
617                out.push('.');
618                out.push_str(f);
619            }
620            (TailSeg::Field(f), "python") | (TailSeg::Field(f), "ruby") => {
621                out.push('.');
622                out.push_str(f);
623            }
624            // node, wasm, typescript, kotlin, dart, swift all use camelCase
625            (TailSeg::Field(f), _) => {
626                out.push('.');
627                out.push_str(&f.to_lower_camel_case());
628            }
629        }
630    }
631
632    out
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638
639    #[test]
640    fn is_streaming_virtual_field_recognizes_all_fields() {
641        for field in STREAMING_VIRTUAL_FIELDS {
642            assert!(
643                is_streaming_virtual_field(field),
644                "field '{field}' not recognized as streaming virtual"
645            );
646        }
647    }
648
649    #[test]
650    fn is_streaming_virtual_field_rejects_real_fields() {
651        assert!(!is_streaming_virtual_field("content"));
652        assert!(!is_streaming_virtual_field("choices"));
653        assert!(!is_streaming_virtual_field("model"));
654        assert!(!is_streaming_virtual_field(""));
655    }
656
657    #[test]
658    fn is_streaming_virtual_field_rejects_non_root_paths_with_matching_tail() {
659        // Regression: prior impl matched any field whose chars-after-root-len started
660        // with `[` or `.` — without checking that the field actually starts with the
661        // root token. `choices[0].finish_reason` therefore falsely matched root
662        // `tool_calls` because byte 10 onward is `.finish_reason`.
663        assert!(!is_streaming_virtual_field("choices[0].finish_reason"));
664        assert!(!is_streaming_virtual_field("choices[0].message.content"));
665        assert!(!is_streaming_virtual_field("usage.total_tokens"));
666        assert!(!is_streaming_virtual_field("data[0].embedding"));
667    }
668
669    #[test]
670    fn accessor_chunks_returns_var_name() {
671        assert_eq!(
672            StreamingFieldResolver::accessor("chunks", "rust", "chunks"),
673            Some("chunks".to_string())
674        );
675        assert_eq!(
676            StreamingFieldResolver::accessor("chunks", "node", "chunks"),
677            Some("chunks".to_string())
678        );
679    }
680
681    #[test]
682    fn accessor_chunks_length_uses_language_idiom() {
683        let rust = StreamingFieldResolver::accessor("chunks.length", "rust", "chunks").unwrap();
684        assert!(rust.contains(".len()"), "rust: {rust}");
685
686        let go = StreamingFieldResolver::accessor("chunks.length", "go", "chunks").unwrap();
687        assert!(go.starts_with("len("), "go: {go}");
688
689        let node = StreamingFieldResolver::accessor("chunks.length", "node", "chunks").unwrap();
690        assert!(node.contains(".length"), "node: {node}");
691
692        let php = StreamingFieldResolver::accessor("chunks.length", "php", "chunks").unwrap();
693        assert!(php.starts_with("count("), "php: {php}");
694    }
695
696    #[test]
697    fn accessor_chunks_length_zig_uses_items_len() {
698        let zig = StreamingFieldResolver::accessor("chunks.length", "zig", "chunks").unwrap();
699        assert_eq!(zig, "chunks.items.len", "zig chunks.length: {zig}");
700    }
701
702    #[test]
703    fn accessor_stream_content_zig_uses_content_items() {
704        let zig = StreamingFieldResolver::accessor("stream_content", "zig", "chunks").unwrap();
705        assert_eq!(zig, "chunks_content.items", "zig stream_content: {zig}");
706    }
707
708    #[test]
709    fn collect_snippet_zig_drains_via_ffi() {
710        let snip = StreamingFieldResolver::collect_snippet("zig", "_stream_handle", "chunks").unwrap();
711        assert!(snip.contains("std.ArrayList([]u8)"), "zig collect: {snip}");
712        assert!(snip.contains("chat_stream_next(_stream_handle)"), "zig collect: {snip}");
713        assert!(snip.contains("chunks_content"), "zig collect: {snip}");
714        assert!(
715            snip.contains("chunks.append(std.heap.c_allocator"),
716            "zig collect: {snip}"
717        );
718        assert!(snip.contains(".empty;"), "zig collect (Zig 0.16 unmanaged): {snip}");
719    }
720
721    #[test]
722    fn accessor_stream_content_rust_uses_iterator() {
723        let expr = StreamingFieldResolver::accessor("stream_content", "rust", "chunks").unwrap();
724        assert!(expr.contains(".collect::<String>()"), "rust stream_content: {expr}");
725    }
726
727    #[test]
728    fn accessor_no_chunks_after_done_returns_true() {
729        for lang in ["rust", "go", "java", "php", "node", "wasm", "elixir"] {
730            let expr = StreamingFieldResolver::accessor("no_chunks_after_done", lang, "chunks").unwrap();
731            assert_eq!(expr, "true", "lang {lang}: expected 'true', got '{expr}'");
732        }
733    }
734
735    #[test]
736    fn accessor_elixir_chunks_length_uses_length_function() {
737        let expr = StreamingFieldResolver::accessor("chunks.length", "elixir", "chunks").unwrap();
738        assert_eq!(expr, "length(chunks)", "elixir chunks.length: {expr}");
739    }
740
741    #[test]
742    fn accessor_elixir_stream_content_uses_pipe() {
743        let expr = StreamingFieldResolver::accessor("stream_content", "elixir", "chunks").unwrap();
744        assert!(expr.contains("|> Enum.join"), "elixir stream_content: {expr}");
745        assert!(expr.contains("|> Enum.map"), "elixir stream_content: {expr}");
746    }
747
748    #[test]
749    fn accessor_elixir_stream_complete_uses_list_last() {
750        let expr = StreamingFieldResolver::accessor("stream_complete", "elixir", "chunks").unwrap();
751        assert!(expr.contains("List.last(chunks)"), "elixir stream_complete: {expr}");
752        assert!(expr.contains("finish_reason != nil"), "elixir stream_complete: {expr}");
753    }
754
755    #[test]
756    fn accessor_elixir_finish_reason_uses_list_last() {
757        let expr = StreamingFieldResolver::accessor("finish_reason", "elixir", "chunks").unwrap();
758        assert!(expr.contains("List.last(chunks)"), "elixir finish_reason: {expr}");
759        assert!(expr.contains("finish_reason"), "elixir finish_reason: {expr}");
760    }
761
762    #[test]
763    fn collect_snippet_elixir_uses_enum_to_list() {
764        let snip = StreamingFieldResolver::collect_snippet("elixir", "result", "chunks").unwrap();
765        assert!(snip.contains("Enum.to_list(result)"), "elixir: {snip}");
766        assert!(snip.contains("chunks ="), "elixir: {snip}");
767    }
768
769    #[test]
770    fn collect_snippet_rust_uses_tokio_stream() {
771        let snip = StreamingFieldResolver::collect_snippet("rust", "result", "chunks").unwrap();
772        assert!(snip.contains("tokio_stream::StreamExt::collect"), "rust: {snip}");
773        assert!(snip.contains("let chunks"), "rust: {snip}");
774        // Items are Result<ChatCompletionChunk, _> — unwrap so chunks is Vec<ChatCompletionChunk>
775        assert!(snip.contains(".expect("), "rust must unwrap Result items: {snip}");
776    }
777
778    #[test]
779    fn collect_snippet_go_drains_channel() {
780        let snip = StreamingFieldResolver::collect_snippet("go", "stream", "chunks").unwrap();
781        assert!(snip.contains("for chunk := range stream"), "go: {snip}");
782    }
783
784    #[test]
785    fn collect_snippet_java_uses_iterator() {
786        let snip = StreamingFieldResolver::collect_snippet("java", "result", "chunks").unwrap();
787        assert!(snip.contains("hasNext()"), "java: {snip}");
788    }
789
790    #[test]
791    fn collect_snippet_php_decodes_json_or_iterates() {
792        let snip = StreamingFieldResolver::collect_snippet("php", "result", "chunks").unwrap();
793        // PHP binding's chat_stream_async returns a JSON string today; collect-snippet
794        // decodes it.  iterator_to_array is retained as the fallback branch so a
795        // future binding that exposes a real iterator continues to work without
796        // regenerating the e2e tests.
797        assert!(snip.contains("json_decode"), "php must decode JSON: {snip}");
798        assert!(
799            snip.contains("iterator_to_array"),
800            "php must keep iterator_to_array fallback: {snip}"
801        );
802        assert!(snip.contains("$chunks ="), "php must bind $chunks: {snip}");
803    }
804
805    #[test]
806    fn collect_snippet_node_uses_for_await() {
807        let snip = StreamingFieldResolver::collect_snippet("node", "result", "chunks").unwrap();
808        assert!(snip.contains("for await"), "node: {snip}");
809    }
810
811    #[test]
812    fn collect_snippet_python_uses_async_for() {
813        let snip = StreamingFieldResolver::collect_snippet("python", "result", "chunks").unwrap();
814        assert!(snip.contains("async for chunk in result"), "python: {snip}");
815        assert!(snip.contains("chunks.append(chunk)"), "python: {snip}");
816    }
817
818    #[test]
819    fn accessor_stream_content_python_uses_join() {
820        let expr = StreamingFieldResolver::accessor("stream_content", "python", "chunks").unwrap();
821        assert!(expr.contains("\"\".join("), "python stream_content: {expr}");
822        assert!(expr.contains("c.choices"), "python stream_content: {expr}");
823    }
824
825    #[test]
826    fn accessor_stream_complete_python_uses_finish_reason() {
827        let expr = StreamingFieldResolver::accessor("stream_complete", "python", "chunks").unwrap();
828        assert!(
829            expr.contains("finish_reason is not None"),
830            "python stream_complete: {expr}"
831        );
832    }
833
834    #[test]
835    fn accessor_finish_reason_python_uses_last_chunk() {
836        let expr = StreamingFieldResolver::accessor("finish_reason", "python", "chunks").unwrap();
837        assert!(expr.contains("chunks[-1]"), "python finish_reason: {expr}");
838    }
839
840    #[test]
841    fn accessor_tool_calls_python_uses_list_comprehension() {
842        let expr = StreamingFieldResolver::accessor("tool_calls", "python", "chunks").unwrap();
843        assert!(expr.contains("for c in chunks"), "python tool_calls: {expr}");
844        assert!(expr.contains("tool_calls"), "python tool_calls: {expr}");
845    }
846
847    #[test]
848    fn accessor_unknown_field_returns_none() {
849        assert_eq!(
850            StreamingFieldResolver::accessor("nonexistent_field", "rust", "chunks"),
851            None
852        );
853    }
854
855    // -----------------------------------------------------------------------
856    // Deep-path tests: tool_calls[0].function.name and tool_calls[0].id
857    // -----------------------------------------------------------------------
858
859    #[test]
860    fn is_streaming_virtual_field_recognizes_deep_tool_calls_paths() {
861        assert!(
862            is_streaming_virtual_field("tool_calls[0].function.name"),
863            "tool_calls[0].function.name should be recognized"
864        );
865        assert!(
866            is_streaming_virtual_field("tool_calls[0].id"),
867            "tool_calls[0].id should be recognized"
868        );
869        assert!(
870            is_streaming_virtual_field("tool_calls[1].function.arguments"),
871            "tool_calls[1].function.arguments should be recognized"
872        );
873        // bare root still recognized
874        assert!(is_streaming_virtual_field("tool_calls"));
875        // unrelated deep path must NOT be recognized
876        assert!(!is_streaming_virtual_field("tool_calls_extra.name"));
877        assert!(!is_streaming_virtual_field("nonexistent[0].field"));
878    }
879
880    /// Snapshot: `tool_calls[0].function.name` for Rust, Kotlin, TypeScript.
881    ///
882    /// These three languages cover the main accessor styles:
883    /// - Rust: snake_case field, explicit `[0]` index on collected Vec
884    /// - Kotlin: camelCase method calls with `.first()` for index 0
885    /// - TypeScript/Node: camelCase properties with `[0]` bracket
886    #[test]
887    fn deep_tool_calls_function_name_snapshot_rust_kotlin_ts() {
888        let field = "tool_calls[0].function.name";
889
890        let rust = StreamingFieldResolver::accessor(field, "rust", "chunks").unwrap();
891        // Rust: Option-aware chain over the iterator — `.nth(0)` then `.and_then`
892        // on each Option-wrapped field (function is Option<StreamFunctionCall>,
893        // name is Option<String>). Final `.unwrap_or("")` yields `&str`.
894        assert!(
895            rust.contains(".nth(0)"),
896            "rust deep tool_calls: expected .nth(0) iterator index, got: {rust}"
897        );
898        assert!(
899            rust.contains("x.function.as_ref()"),
900            "rust deep tool_calls: expected Option-aware function access, got: {rust}"
901        );
902        assert!(
903            rust.contains("x.name.as_deref()"),
904            "rust deep tool_calls: expected Option-aware name leaf, got: {rust}"
905        );
906        assert!(
907            !rust.contains("// skipped"),
908            "rust deep tool_calls: must not emit skip comment, got: {rust}"
909        );
910
911        let kotlin = StreamingFieldResolver::accessor(field, "kotlin", "chunks").unwrap();
912        // Kotlin: uses .first() for index 0, then .function().name()
913        assert!(
914            kotlin.contains(".first()"),
915            "kotlin deep tool_calls: expected .first() for index 0, got: {kotlin}"
916        );
917        assert!(
918            kotlin.contains(".function()"),
919            "kotlin deep tool_calls: expected .function() method call, got: {kotlin}"
920        );
921        assert!(
922            kotlin.contains(".name()"),
923            "kotlin deep tool_calls: expected .name() method call, got: {kotlin}"
924        );
925
926        let ts = StreamingFieldResolver::accessor(field, "node", "chunks").unwrap();
927        // TypeScript/Node: uses [0] then .function.name (camelCase)
928        assert!(
929            ts.contains("[0]"),
930            "ts/node deep tool_calls: expected [0] index, got: {ts}"
931        );
932        assert!(
933            ts.contains(".function"),
934            "ts/node deep tool_calls: expected .function segment, got: {ts}"
935        );
936        assert!(
937            ts.contains(".name"),
938            "ts/node deep tool_calls: expected .name segment, got: {ts}"
939        );
940    }
941
942    #[test]
943    fn deep_tool_calls_id_snapshot_all_langs() {
944        let field = "tool_calls[0].id";
945
946        let rust = StreamingFieldResolver::accessor(field, "rust", "chunks").unwrap();
947        assert!(rust.contains(".nth(0)"), "rust: {rust}");
948        assert!(rust.contains("x.id.as_deref()"), "rust: {rust}");
949
950        let go = StreamingFieldResolver::accessor(field, "go", "chunks").unwrap();
951        assert!(go.contains("[0]"), "go: {go}");
952        // Go: ID is a well-known initialism → uppercase
953        assert!(go.contains(".ID"), "go: expected .ID initialism, got: {go}");
954
955        let python = StreamingFieldResolver::accessor(field, "python", "chunks").unwrap();
956        assert!(python.contains("[0]"), "python: {python}");
957        assert!(python.contains(".id"), "python: {python}");
958
959        let php = StreamingFieldResolver::accessor(field, "php", "chunks").unwrap();
960        assert!(php.contains("[0]"), "php: {php}");
961        assert!(php.contains("->id"), "php: expected ->id, got: {php}");
962
963        let java = StreamingFieldResolver::accessor(field, "java", "chunks").unwrap();
964        assert!(java.contains(".get(0)"), "java: expected .get(0), got: {java}");
965        assert!(java.contains(".id()"), "java: expected .id() method call, got: {java}");
966
967        let csharp = StreamingFieldResolver::accessor(field, "csharp", "chunks").unwrap();
968        assert!(csharp.contains("[0]"), "csharp: {csharp}");
969        assert!(
970            csharp.contains(".Id"),
971            "csharp: expected .Id (PascalCase), got: {csharp}"
972        );
973
974        let elixir = StreamingFieldResolver::accessor(field, "elixir", "chunks").unwrap();
975        assert!(elixir.contains("Enum.at("), "elixir: expected Enum.at(, got: {elixir}");
976        assert!(elixir.contains(".id"), "elixir: {elixir}");
977    }
978
979    #[test]
980    fn deep_tool_calls_function_name_snapshot_python_elixir_zig() {
981        let field = "tool_calls[0].function.name";
982
983        let python = StreamingFieldResolver::accessor(field, "python", "chunks").unwrap();
984        assert!(python.contains("[0]"), "python: {python}");
985        assert!(python.contains(".function"), "python: {python}");
986        assert!(python.contains(".name"), "python: {python}");
987
988        let elixir = StreamingFieldResolver::accessor(field, "elixir", "chunks").unwrap();
989        // Elixir: Enum.at(…, 0).function.name
990        assert!(elixir.contains("Enum.at("), "elixir: {elixir}");
991        assert!(elixir.contains(".function"), "elixir: {elixir}");
992        assert!(elixir.contains(".name"), "elixir: {elixir}");
993
994        // Zig stores chunks as JSON strings, not typed records — deep
995        // tool_calls paths are unsupported and resolve to None so the
996        // assertion site can skip them.
997        assert!(
998            StreamingFieldResolver::accessor(field, "zig", "chunks").is_none(),
999            "zig: expected None for deep tool_calls path"
1000        );
1001    }
1002
1003    #[test]
1004    fn parse_tail_parses_index_then_field_segments() {
1005        let segs = parse_tail("[0].function.name");
1006        assert_eq!(segs.len(), 3, "expected 3 segments, got: {segs:?}");
1007        assert_eq!(segs[0], TailSeg::Index(0));
1008        assert_eq!(segs[1], TailSeg::Field("function".to_string()));
1009        assert_eq!(segs[2], TailSeg::Field("name".to_string()));
1010    }
1011
1012    #[test]
1013    fn parse_tail_parses_simple_index_field() {
1014        let segs = parse_tail("[0].id");
1015        assert_eq!(segs.len(), 2, "expected 2 segments, got: {segs:?}");
1016        assert_eq!(segs[0], TailSeg::Index(0));
1017        assert_eq!(segs[1], TailSeg::Field("id".to_string()));
1018    }
1019
1020    #[test]
1021    fn parse_tail_handles_nonzero_index() {
1022        let segs = parse_tail("[2].function.arguments");
1023        assert_eq!(segs[0], TailSeg::Index(2));
1024        assert_eq!(segs[1], TailSeg::Field("function".to_string()));
1025        assert_eq!(segs[2], TailSeg::Field("arguments".to_string()));
1026    }
1027}