Skip to main content

nu_protocol/
last_result.rs

1//! Interactive last-result (`$ans` by default) helpers: truncation and AST detection.
2
3use crate::{
4    LAST_VARIABLE_ID, Span, Value,
5    ast::{Block, Expr, Expression, Pipeline},
6};
7
8/// Truncate `value` so that [`Value::memory_size`] is at most `budget` bytes.
9///
10/// Returns the (possibly truncated) value and whether truncation occurred.
11///
12/// Strategy:
13/// - **Lists of records (tables):** keep a leading prefix of *whole rows* only. Partial rows
14///   (or `nothing` fillers) would break homogeneous-table rendering and collapse to
15///   `{record N fields}` list view.
16/// - **Other lists:** keep a leading prefix of whole items; for trailing scalars/strings/binary
17///   only, a partial last item is allowed when it still fits cleanly.
18/// - Records (standalone): fill fields left-to-right until the budget is exhausted
19/// - Strings / binary / globs: keep a byte prefix
20/// - Other scalar-ish values: keep whole if they fit, otherwise replace with `nothing`
21pub fn truncate_value_to_budget(value: Value, budget: usize) -> (Value, bool) {
22    if budget == 0 {
23        return (Value::nothing(value.span()), true);
24    }
25
26    if value.memory_size() <= budget {
27        return (value, false);
28    }
29
30    let span = value.span();
31    match value {
32        Value::List { vals, .. } => truncate_list(vals.into_owned(), budget, span),
33        Value::Record { val, .. } => truncate_record(val.into_owned(), budget, span),
34        Value::String { val, .. } => truncate_string(val, budget, span),
35        Value::Binary { val, .. } => truncate_binary(val.into_owned(), budget, span),
36        Value::Glob { val, .. } => {
37            // Truncated globs become plain strings (prefix of the pattern).
38            truncate_string(val, budget, span)
39        }
40        other => {
41            // Cannot partially shrink; store nothing and mark truncated.
42            drop(other);
43            (Value::nothing(span), true)
44        }
45    }
46}
47
48fn truncate_list(vals: Vec<Value>, budget: usize, span: Span) -> (Value, bool) {
49    // Base cost of the list shell; keep adding items while they fit.
50    let base = Value::list(vec![], span).memory_size();
51    if base > budget {
52        return (Value::nothing(span), true);
53    }
54
55    let original_len = vals.len();
56    // Table-like lists must keep whole rows so `table` still expands columns.
57    let table_like = !vals.is_empty() && vals.iter().all(|v| matches!(v, Value::Record { .. }));
58
59    let mut kept = Vec::new();
60    let mut used = base;
61    let mut truncated = false;
62
63    for item in vals {
64        let item_size = item.memory_size();
65        if used.saturating_add(item_size) <= budget {
66            used += item_size;
67            kept.push(item);
68            continue;
69        }
70
71        // Item does not fit whole.
72        truncated = true;
73
74        if table_like {
75            // Drop this row and stop — do not emit a partial record or `nothing`.
76            break;
77        }
78
79        // For non-table lists, allow a partial last scalar/string/binary only.
80        // Never push `nothing` or empty placeholders that change list shape.
81        let remaining = budget.saturating_sub(used);
82        if remaining > 0 && can_partially_truncate_list_item(&item) {
83            let (partial, _) = truncate_value_to_budget(item, remaining);
84            if !matches!(partial, Value::Nothing { .. })
85                && used.saturating_add(partial.memory_size()) <= budget
86            {
87                kept.push(partial);
88            }
89        }
90        break;
91    }
92
93    if kept.len() < original_len {
94        truncated = true;
95    }
96
97    let out = Value::list(kept, span);
98    let still_over = out.memory_size() > budget;
99    (out, truncated || still_over)
100}
101
102/// Whether a list item is a type we are willing to partially shrink as the last element.
103fn can_partially_truncate_list_item(item: &Value) -> bool {
104    matches!(
105        item,
106        Value::String { .. } | Value::Binary { .. } | Value::Glob { .. } | Value::List { .. }
107    )
108}
109
110fn truncate_record(record: crate::Record, budget: usize, span: Span) -> (Value, bool) {
111    let base = Value::record(crate::Record::new(), span).memory_size();
112    if base > budget {
113        return (Value::nothing(span), true);
114    }
115
116    let mut out = crate::Record::new();
117    let mut used = base;
118    let mut truncated = false;
119    let original_len = record.len();
120
121    for (key, val) in record {
122        let key_cost = key.capacity();
123        let remaining = budget.saturating_sub(used.saturating_add(key_cost));
124        if remaining == 0 {
125            truncated = true;
126            break;
127        }
128
129        let (stored_val, val_trunc) = if val.memory_size() <= remaining {
130            (val, false)
131        } else {
132            let (v, t) = truncate_value_to_budget(val, remaining);
133            (v, t)
134        };
135
136        let entry_size = key_cost + stored_val.memory_size();
137        if used.saturating_add(entry_size) > budget {
138            truncated = true;
139            break;
140        }
141        used += entry_size;
142        truncated |= val_trunc;
143        out.push(key, stored_val);
144    }
145
146    if out.len() < original_len {
147        truncated = true;
148    }
149
150    (Value::record(out, span), truncated)
151}
152
153fn truncate_string(val: String, budget: usize, span: Span) -> (Value, bool) {
154    // memory_size for string is size_of::<Value>() + capacity.
155    let whole = Value::string(val, span);
156    if whole.memory_size() <= budget {
157        return (whole, false);
158    }
159
160    let val = match whole {
161        Value::String { val, .. } => val,
162        other => {
163            // Only strings are passed here; fall back without panicking.
164            drop(other);
165            return (Value::nothing(span), true);
166        }
167    };
168
169    if std::mem::size_of::<Value>() > budget {
170        return (Value::nothing(span), true);
171    }
172
173    // Shrink content until Value::memory_size fits. capacity of a freshly built String equals len.
174    let mut end = budget
175        .saturating_sub(std::mem::size_of::<Value>())
176        .min(val.len());
177    while end > 0 && !val.is_char_boundary(end) {
178        end -= 1;
179    }
180
181    loop {
182        let prefix = val[..end].to_string();
183        let out = Value::string(prefix, span);
184        if out.memory_size() <= budget {
185            return (out, true);
186        }
187        if end == 0 {
188            return (Value::nothing(span), true);
189        }
190        end -= 1;
191        while end > 0 && !val.is_char_boundary(end) {
192            end -= 1;
193        }
194    }
195}
196
197fn truncate_binary(val: Vec<u8>, budget: usize, span: Span) -> (Value, bool) {
198    let whole = Value::binary(val, span);
199    if whole.memory_size() <= budget {
200        return (whole, false);
201    }
202
203    let val = match whole {
204        Value::Binary { val, .. } => val,
205        other => {
206            drop(other);
207            return (Value::nothing(span), true);
208        }
209    };
210
211    if std::mem::size_of::<Value>() > budget {
212        return (Value::nothing(span), true);
213    }
214
215    let mut end = budget
216        .saturating_sub(std::mem::size_of::<Value>())
217        .min(val.len());
218    loop {
219        let out = Value::binary(val[..end].to_vec(), span);
220        if out.memory_size() <= budget {
221            return (out, true);
222        }
223        if end == 0 {
224            return (Value::nothing(span), true);
225        }
226        end -= 1;
227    }
228}
229
230/// True when `value` is a [`Value::Error`] or a non-empty list of only errors.
231///
232/// Commands like `str length` embed type mismatches as values via `operate()`
233/// instead of failing the pipeline. Interactive last-result must not replace
234/// `$ans.last` with those payloads (same policy as a thrown runtime error).
235/// Mixed lists (some errors, some values) are real results and return false.
236pub fn value_is_error_only(value: &Value) -> bool {
237    match value {
238        Value::Error { .. } => true,
239        Value::List { vals, .. } => !vals.is_empty() && vals.iter().all(Value::is_error),
240        _ => false,
241    }
242}
243
244/// Returns true when `block` is only a reference to the last-result variable or a
245/// cell-path rooted at it (e.g. `$ans`, `$ans.last`, `$ans.exit_code`, `$ans.command`),
246/// optionally wrapped in parentheses / a single-element pipeline.
247///
248/// Such expressions must not overwrite `$ans.last` when re-evaluated.
249pub fn block_is_bare_last_result(block: &Block) -> bool {
250    if block.pipelines.len() != 1 {
251        return false;
252    }
253    pipeline_is_bare_last_result(&block.pipelines[0])
254}
255
256fn pipeline_is_bare_last_result(pipeline: &Pipeline) -> bool {
257    if pipeline.elements.len() != 1 {
258        return false;
259    }
260    let element = &pipeline.elements[0];
261    if element.redirection.is_some() {
262        return false;
263    }
264    expr_is_bare_last_result(&element.expr)
265}
266
267fn expr_is_bare_last_result(expr: &Expression) -> bool {
268    match &expr.expr {
269        Expr::Var(var_id) => *var_id == LAST_VARIABLE_ID,
270        Expr::FullCellPath(path) => expr_is_bare_last_result(&path.head),
271        // Parenthesized subexpression: `($ans)` / `($ans.last)`
272        Expr::Block(block_id) | Expr::RowCondition(block_id) | Expr::Closure(block_id) => {
273            // These shouldn't appear for simple paren groups; paren groups are usually Subexpression
274            let _ = block_id;
275            false
276        }
277        Expr::Subexpression(block_id) => {
278            // Handled at call site if we have working set; treat conservatively as false here
279            // unless we only have the Expression. Callers with EngineState should use the
280            // overload below. Without block body we can't know — return false.
281            let _ = block_id;
282            false
283        }
284        _ => false,
285    }
286}
287
288/// Like [`block_is_bare_last_result`] but expands subexpressions via `get_block`.
289///
290/// Uses a trait object so recursive subexpression walks do not monomorphize infinitely.
291pub fn block_is_bare_last_result_with<'a>(
292    block: &Block,
293    get_block: &mut dyn FnMut(crate::BlockId) -> &'a Block,
294) -> bool {
295    if block.pipelines.len() != 1 {
296        return false;
297    }
298    let pipeline = &block.pipelines[0];
299    if pipeline.elements.len() != 1 {
300        return false;
301    }
302    let element = &pipeline.elements[0];
303    if element.redirection.is_some() {
304        return false;
305    }
306    expr_is_bare_last_result_with(&element.expr, get_block)
307}
308
309fn expr_is_bare_last_result_with<'a>(
310    expr: &Expression,
311    get_block: &mut dyn FnMut(crate::BlockId) -> &'a Block,
312) -> bool {
313    match &expr.expr {
314        Expr::Var(var_id) => *var_id == LAST_VARIABLE_ID,
315        Expr::FullCellPath(path) => expr_is_bare_last_result_with(&path.head, get_block),
316        Expr::Subexpression(block_id) => {
317            let inner = get_block(*block_id);
318            block_is_bare_last_result_with(inner, get_block)
319        }
320        _ => false,
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use crate::record;
328
329    #[test]
330    fn under_budget_unchanged() {
331        let v = Value::test_int(42);
332        let size = v.memory_size();
333        let (out, truncated) = truncate_value_to_budget(v.clone(), size);
334        assert!(!truncated);
335        assert_eq!(out, v);
336        assert!(out.memory_size() <= size);
337    }
338
339    #[test]
340    fn list_prefix_respects_budget() {
341        let items: Vec<_> = (0..100).map(Value::test_int).collect();
342        let list = Value::test_list(items);
343        let full = list.memory_size();
344        assert!(full > 0);
345
346        // Budget that can only fit a few ints
347        let one = Value::test_int(0).memory_size();
348        let budget = Value::test_list(vec![]).memory_size() + one * 3;
349        let (out, truncated) = truncate_value_to_budget(list, budget);
350        assert!(truncated);
351        assert!(out.memory_size() <= budget);
352        match out {
353            Value::List { vals, .. } => {
354                assert!(vals.len() <= 3);
355                assert!(
356                    !vals.is_empty()
357                        || budget < Value::test_list(vec![Value::test_int(0)]).memory_size()
358                );
359            }
360            Value::Nothing { .. } => {
361                // Acceptable only if budget was extremely tight
362                assert!(budget < Value::test_list(vec![Value::test_int(0)]).memory_size());
363            }
364            other => panic!("unexpected truncated type: {other:?}"),
365        }
366    }
367
368    #[test]
369    fn string_prefix_respects_budget() {
370        let s = "x".repeat(10_000);
371        let val = Value::test_string(s);
372        let budget = std::mem::size_of::<Value>() + 100;
373        let (out, truncated) = truncate_value_to_budget(val, budget);
374        assert!(truncated);
375        assert!(out.memory_size() <= budget);
376        if let Value::String { val, .. } = out {
377            assert!(val.len() <= 100);
378        }
379    }
380
381    #[test]
382    fn binary_prefix_respects_budget() {
383        let val = Value::test_binary(vec![0u8; 10_000]);
384        let budget = std::mem::size_of::<Value>() + 64;
385        let (out, truncated) = truncate_value_to_budget(val, budget);
386        assert!(truncated);
387        assert!(out.memory_size() <= budget);
388        if let Value::Binary { val, .. } = out {
389            assert!(val.len() <= 64);
390        }
391    }
392
393    #[test]
394    fn zero_budget_yields_nothing() {
395        let v = Value::test_string("hello");
396        let (out, truncated) = truncate_value_to_budget(v, 0);
397        assert!(truncated);
398        assert!(matches!(out, Value::Nothing { .. }));
399    }
400
401    #[test]
402    fn table_like_list_keeps_whole_records_only() {
403        // ls-style rows: homogeneous records must stay full so `table` expands columns.
404        let row = |name: &str| {
405            Value::test_record(record! {
406                "name" => Value::test_string(name),
407                "type" => Value::test_string("file"),
408                "size" => Value::test_int(1),
409                "modified" => Value::test_string("now"),
410            })
411        };
412        let rows: Vec<_> = (0..50).map(|i| row(&format!("f{i}.txt"))).collect();
413        let list = Value::test_list(rows);
414        let one = row("x").memory_size();
415        let budget = Value::test_list(vec![]).memory_size() + one * 3 + one / 2;
416
417        let (out, truncated) = truncate_value_to_budget(list, budget);
418        assert!(truncated);
419        assert!(out.memory_size() <= budget);
420
421        let Value::List { vals, .. } = out else {
422            panic!("expected list, got {out:?}");
423        };
424        assert!(vals.len() <= 3);
425        assert!(!vals.is_empty());
426        // Every kept row must be a full record with the same columns (no nothing fillers).
427        for v in &vals {
428            match v {
429                Value::Record { val, .. } => {
430                    assert_eq!(val.len(), 4, "row must keep all columns for table display");
431                    assert!(val.get("name").is_some());
432                    assert!(val.get("type").is_some());
433                    assert!(val.get("size").is_some());
434                    assert!(val.get("modified").is_some());
435                }
436                other => panic!("expected full record row, got {other:?}"),
437            }
438        }
439    }
440
441    #[test]
442    fn record_fields_respect_budget() {
443        let rec = Value::test_record(record! {
444            "a" => Value::test_string("x".repeat(5000)),
445            "b" => Value::test_string("y".repeat(5000)),
446        });
447        let budget = rec.memory_size() / 2;
448        let (out, truncated) = truncate_value_to_budget(rec, budget);
449        assert!(truncated);
450        assert!(out.memory_size() <= budget);
451    }
452
453    fn error_value() -> Value {
454        Value::error(
455            crate::ShellError::Generic(crate::shell_error::generic::GenericError::new(
456                "boom",
457                "",
458                Span::test_data(),
459            )),
460            Span::test_data(),
461        )
462    }
463
464    #[test]
465    fn error_only_detects_error_value() {
466        assert!(value_is_error_only(&error_value()));
467        assert!(!value_is_error_only(&Value::test_int(1)));
468        assert!(!value_is_error_only(&Value::test_nothing()));
469    }
470
471    #[test]
472    fn error_only_detects_nonempty_error_list() {
473        let list = Value::test_list(vec![error_value(), error_value()]);
474        assert!(value_is_error_only(&list));
475        assert!(!value_is_error_only(&Value::test_list(vec![])));
476    }
477
478    #[test]
479    fn error_only_rejects_mixed_list() {
480        let list = Value::test_list(vec![error_value(), Value::test_int(2)]);
481        assert!(!value_is_error_only(&list));
482    }
483}