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/// Returns true when `block` is only a reference to the last-result variable or a
231/// cell-path rooted at it (e.g. `$ans`, `$ans.last`, `$ans.exit_code`, `$ans.command`),
232/// optionally wrapped in parentheses / a single-element pipeline.
233///
234/// Such expressions must not overwrite `$ans.last` when re-evaluated.
235pub fn block_is_bare_last_result(block: &Block) -> bool {
236    if block.pipelines.len() != 1 {
237        return false;
238    }
239    pipeline_is_bare_last_result(&block.pipelines[0])
240}
241
242fn pipeline_is_bare_last_result(pipeline: &Pipeline) -> bool {
243    if pipeline.elements.len() != 1 {
244        return false;
245    }
246    let element = &pipeline.elements[0];
247    if element.redirection.is_some() {
248        return false;
249    }
250    expr_is_bare_last_result(&element.expr)
251}
252
253fn expr_is_bare_last_result(expr: &Expression) -> bool {
254    match &expr.expr {
255        Expr::Var(var_id) => *var_id == LAST_VARIABLE_ID,
256        Expr::FullCellPath(path) => expr_is_bare_last_result(&path.head),
257        // Parenthesized subexpression: `($ans)` / `($ans.last)`
258        Expr::Block(block_id) | Expr::RowCondition(block_id) | Expr::Closure(block_id) => {
259            // These shouldn't appear for simple paren groups; paren groups are usually Subexpression
260            let _ = block_id;
261            false
262        }
263        Expr::Subexpression(block_id) => {
264            // Handled at call site if we have working set; treat conservatively as false here
265            // unless we only have the Expression. Callers with EngineState should use the
266            // overload below. Without block body we can't know — return false.
267            let _ = block_id;
268            false
269        }
270        _ => false,
271    }
272}
273
274/// Like [`block_is_bare_last_result`] but expands subexpressions via `get_block`.
275///
276/// Uses a trait object so recursive subexpression walks do not monomorphize infinitely.
277pub fn block_is_bare_last_result_with<'a>(
278    block: &Block,
279    get_block: &mut dyn FnMut(crate::BlockId) -> &'a Block,
280) -> bool {
281    if block.pipelines.len() != 1 {
282        return false;
283    }
284    let pipeline = &block.pipelines[0];
285    if pipeline.elements.len() != 1 {
286        return false;
287    }
288    let element = &pipeline.elements[0];
289    if element.redirection.is_some() {
290        return false;
291    }
292    expr_is_bare_last_result_with(&element.expr, get_block)
293}
294
295fn expr_is_bare_last_result_with<'a>(
296    expr: &Expression,
297    get_block: &mut dyn FnMut(crate::BlockId) -> &'a Block,
298) -> bool {
299    match &expr.expr {
300        Expr::Var(var_id) => *var_id == LAST_VARIABLE_ID,
301        Expr::FullCellPath(path) => expr_is_bare_last_result_with(&path.head, get_block),
302        Expr::Subexpression(block_id) => {
303            let inner = get_block(*block_id);
304            block_is_bare_last_result_with(inner, get_block)
305        }
306        _ => false,
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use crate::record;
314
315    #[test]
316    fn under_budget_unchanged() {
317        let v = Value::test_int(42);
318        let size = v.memory_size();
319        let (out, truncated) = truncate_value_to_budget(v.clone(), size);
320        assert!(!truncated);
321        assert_eq!(out, v);
322        assert!(out.memory_size() <= size);
323    }
324
325    #[test]
326    fn list_prefix_respects_budget() {
327        let items: Vec<_> = (0..100).map(Value::test_int).collect();
328        let list = Value::test_list(items);
329        let full = list.memory_size();
330        assert!(full > 0);
331
332        // Budget that can only fit a few ints
333        let one = Value::test_int(0).memory_size();
334        let budget = Value::test_list(vec![]).memory_size() + one * 3;
335        let (out, truncated) = truncate_value_to_budget(list, budget);
336        assert!(truncated);
337        assert!(out.memory_size() <= budget);
338        match out {
339            Value::List { vals, .. } => {
340                assert!(vals.len() <= 3);
341                assert!(
342                    !vals.is_empty()
343                        || budget < Value::test_list(vec![Value::test_int(0)]).memory_size()
344                );
345            }
346            Value::Nothing { .. } => {
347                // Acceptable only if budget was extremely tight
348                assert!(budget < Value::test_list(vec![Value::test_int(0)]).memory_size());
349            }
350            other => panic!("unexpected truncated type: {other:?}"),
351        }
352    }
353
354    #[test]
355    fn string_prefix_respects_budget() {
356        let s = "x".repeat(10_000);
357        let val = Value::test_string(s);
358        let budget = std::mem::size_of::<Value>() + 100;
359        let (out, truncated) = truncate_value_to_budget(val, budget);
360        assert!(truncated);
361        assert!(out.memory_size() <= budget);
362        if let Value::String { val, .. } = out {
363            assert!(val.len() <= 100);
364        }
365    }
366
367    #[test]
368    fn binary_prefix_respects_budget() {
369        let val = Value::test_binary(vec![0u8; 10_000]);
370        let budget = std::mem::size_of::<Value>() + 64;
371        let (out, truncated) = truncate_value_to_budget(val, budget);
372        assert!(truncated);
373        assert!(out.memory_size() <= budget);
374        if let Value::Binary { val, .. } = out {
375            assert!(val.len() <= 64);
376        }
377    }
378
379    #[test]
380    fn zero_budget_yields_nothing() {
381        let v = Value::test_string("hello");
382        let (out, truncated) = truncate_value_to_budget(v, 0);
383        assert!(truncated);
384        assert!(matches!(out, Value::Nothing { .. }));
385    }
386
387    #[test]
388    fn table_like_list_keeps_whole_records_only() {
389        // ls-style rows: homogeneous records must stay full so `table` expands columns.
390        let row = |name: &str| {
391            Value::test_record(record! {
392                "name" => Value::test_string(name),
393                "type" => Value::test_string("file"),
394                "size" => Value::test_int(1),
395                "modified" => Value::test_string("now"),
396            })
397        };
398        let rows: Vec<_> = (0..50).map(|i| row(&format!("f{i}.txt"))).collect();
399        let list = Value::test_list(rows);
400        let one = row("x").memory_size();
401        let budget = Value::test_list(vec![]).memory_size() + one * 3 + one / 2;
402
403        let (out, truncated) = truncate_value_to_budget(list, budget);
404        assert!(truncated);
405        assert!(out.memory_size() <= budget);
406
407        let Value::List { vals, .. } = out else {
408            panic!("expected list, got {out:?}");
409        };
410        assert!(vals.len() <= 3);
411        assert!(!vals.is_empty());
412        // Every kept row must be a full record with the same columns (no nothing fillers).
413        for v in &vals {
414            match v {
415                Value::Record { val, .. } => {
416                    assert_eq!(val.len(), 4, "row must keep all columns for table display");
417                    assert!(val.get("name").is_some());
418                    assert!(val.get("type").is_some());
419                    assert!(val.get("size").is_some());
420                    assert!(val.get("modified").is_some());
421                }
422                other => panic!("expected full record row, got {other:?}"),
423            }
424        }
425    }
426
427    #[test]
428    fn record_fields_respect_budget() {
429        let rec = Value::test_record(record! {
430            "a" => Value::test_string("x".repeat(5000)),
431            "b" => Value::test_string("y".repeat(5000)),
432        });
433        let budget = rec.memory_size() / 2;
434        let (out, truncated) = truncate_value_to_budget(rec, budget);
435        assert!(truncated);
436        assert!(out.memory_size() <= budget);
437    }
438}