nu_table/types/
expanded.rs

1use std::cmp::max;
2
3use nu_color_config::{Alignment, StyleComputer, TextStyle};
4use nu_engine::column::get_columns;
5use nu_protocol::{Config, Record, ShellError, Span, Value};
6
7use crate::{
8    NuTable, TableOpts, TableOutput,
9    common::{
10        INDEX_COLUMN_NAME, NuText, StringResult, TableResult, check_value, configure_table,
11        error_sign, get_header_style, get_index_style, load_theme, nu_value_to_string,
12        nu_value_to_string_clean, nu_value_to_string_colored, wrap_text,
13    },
14    string_width,
15    types::has_index,
16};
17
18#[derive(Debug, Clone)]
19pub struct ExpandedTable {
20    expand_limit: Option<usize>,
21    flatten: bool,
22    flatten_sep: String,
23}
24
25impl ExpandedTable {
26    pub fn new(expand_limit: Option<usize>, flatten: bool, flatten_sep: String) -> Self {
27        Self {
28            expand_limit,
29            flatten,
30            flatten_sep,
31        }
32    }
33
34    pub fn build_value(self, item: &Value, opts: TableOpts<'_>) -> NuText {
35        let cfg = Cfg { opts, format: self };
36        let cell = expand_entry(item, cfg);
37        (cell.text, cell.style)
38    }
39
40    pub fn build_map(self, record: &Record, opts: TableOpts<'_>) -> StringResult {
41        let cfg = Cfg { opts, format: self };
42        expanded_table_kv(record, cfg).map(|cell| cell.map(|cell| cell.text))
43    }
44
45    pub fn build_list(self, vals: &[Value], opts: TableOpts<'_>) -> StringResult {
46        let cfg = Cfg { opts, format: self };
47        let output = expand_list(vals, cfg.clone())?;
48        let mut output = match output {
49            Some(out) => out,
50            None => return Ok(None),
51        };
52
53        configure_table(
54            &mut output,
55            cfg.opts.config,
56            &cfg.opts.style_computer,
57            cfg.opts.mode,
58        );
59
60        maybe_expand_table(output, cfg.opts.width)
61    }
62}
63
64#[derive(Debug, Clone)]
65struct Cfg<'a> {
66    opts: TableOpts<'a>,
67    format: ExpandedTable,
68}
69
70#[derive(Debug, Clone)]
71struct CellOutput {
72    text: String,
73    style: TextStyle,
74    size: usize,
75    is_expanded: bool,
76}
77
78impl CellOutput {
79    fn new(text: String, style: TextStyle, size: usize, is_expanded: bool) -> Self {
80        Self {
81            text,
82            style,
83            size,
84            is_expanded,
85        }
86    }
87
88    fn clean(text: String, size: usize, is_expanded: bool) -> Self {
89        Self::new(text, Default::default(), size, is_expanded)
90    }
91
92    fn text(text: String) -> Self {
93        Self::styled((text, Default::default()))
94    }
95
96    fn styled(text: NuText) -> Self {
97        Self::new(text.0, text.1, 1, false)
98    }
99}
100
101type CellResult = Result<Option<CellOutput>, ShellError>;
102
103fn expand_list(input: &[Value], cfg: Cfg<'_>) -> TableResult {
104    const SPLIT_LINE_SPACE: usize = 1;
105    const MIN_CELL_WIDTH: usize = 3;
106    const TRUNCATE_CONTENT_WIDTH: usize = 3;
107
108    if input.is_empty() {
109        return Ok(None);
110    }
111
112    let pad_width = cfg.opts.config.table.padding.left + cfg.opts.config.table.padding.right;
113    let extra_width = pad_width + SPLIT_LINE_SPACE;
114    let truncate_column_width = TRUNCATE_CONTENT_WIDTH + pad_width;
115
116    // 2 - split lines
117    let mut available_width = cfg
118        .opts
119        .width
120        .saturating_sub(SPLIT_LINE_SPACE + SPLIT_LINE_SPACE);
121    if available_width < MIN_CELL_WIDTH {
122        return Ok(None);
123    }
124
125    let headers = get_columns(input);
126    let with_index = has_index(&cfg.opts, &headers);
127
128    // The header with the INDEX is removed from the table headers since
129    // it is added to the natural table index
130    let headers: Vec<_> = headers
131        .into_iter()
132        .filter(|header| header != INDEX_COLUMN_NAME)
133        .collect();
134    let with_header = !headers.is_empty();
135    let row_offset = cfg.opts.index_offset;
136
137    let mut total_rows = 0usize;
138
139    if !with_index && !with_header {
140        if available_width <= extra_width {
141            // it means we have no space left for actual content;
142            // which means there's no point in index itself if it was even used.
143            // so we do not print it.
144            return Ok(None);
145        }
146
147        available_width -= pad_width;
148
149        let mut table = NuTable::new(input.len(), 1);
150        table.set_index_style(get_index_style(&cfg.opts.style_computer));
151        table.set_header_style(get_header_style(&cfg.opts.style_computer));
152        table.set_indent(cfg.opts.config.table.padding);
153
154        for (row, item) in input.iter().enumerate() {
155            cfg.opts.signals.check(cfg.opts.span)?;
156            check_value(item)?;
157
158            let inner_cfg = cfg_expand_reset_table(cfg.clone(), available_width);
159            let cell = expand_entry(item, inner_cfg);
160
161            table.insert((row, 0), cell.text);
162            table.insert_style((row, 0), cell.style);
163
164            total_rows = total_rows.saturating_add(cell.size);
165        }
166
167        return Ok(Some(TableOutput::new(table, false, false, total_rows)));
168    }
169
170    if !with_header && with_index {
171        let mut table = NuTable::new(input.len(), 2);
172        table.set_index_style(get_index_style(&cfg.opts.style_computer));
173        table.set_header_style(get_header_style(&cfg.opts.style_computer));
174        table.set_indent(cfg.opts.config.table.padding);
175
176        let mut index_column_width = 0;
177
178        for (row, item) in input.iter().enumerate() {
179            cfg.opts.signals.check(cfg.opts.span)?;
180            check_value(item)?;
181
182            let index = row + row_offset;
183            let index_value = item
184                .as_record()
185                .ok()
186                .and_then(|val| val.get(INDEX_COLUMN_NAME))
187                .map(|value| value.to_expanded_string("", cfg.opts.config))
188                .unwrap_or_else(|| index.to_string());
189            let index_width = string_width(&index_value);
190            if available_width <= index_width + extra_width + pad_width {
191                // NOTE: we don't wanna wrap index; so we return
192                return Ok(None);
193            }
194
195            table.insert((row, 0), index_value);
196
197            index_column_width = max(index_column_width, index_width);
198        }
199
200        available_width -= index_column_width + extra_width + pad_width;
201
202        for (row, item) in input.iter().enumerate() {
203            cfg.opts.signals.check(cfg.opts.span)?;
204            check_value(item)?;
205
206            let inner_cfg = cfg_expand_reset_table(cfg.clone(), available_width);
207            let cell = expand_entry(item, inner_cfg);
208
209            table.insert((row, 1), cell.text);
210            table.insert_style((row, 1), cell.style);
211
212            total_rows = total_rows.saturating_add(cell.size);
213        }
214
215        return Ok(Some(TableOutput::new(table, false, true, total_rows)));
216    }
217
218    // NOTE: redefine to not break above logic (fixme)
219    let mut available_width = cfg.opts.width - SPLIT_LINE_SPACE;
220
221    let mut table = NuTable::new(input.len() + 1, headers.len() + with_index as usize);
222    table.set_index_style(get_index_style(&cfg.opts.style_computer));
223    table.set_header_style(get_header_style(&cfg.opts.style_computer));
224    table.set_indent(cfg.opts.config.table.padding);
225
226    let mut widths = Vec::new();
227
228    if with_index {
229        table.insert((0, 0), String::from("#"));
230
231        let mut index_column_width = 1;
232
233        for (row, item) in input.iter().enumerate() {
234            cfg.opts.signals.check(cfg.opts.span)?;
235            check_value(item)?;
236
237            let index = row + row_offset;
238            let index_value = item
239                .as_record()
240                .ok()
241                .and_then(|val| val.get(INDEX_COLUMN_NAME))
242                .map(|value| value.to_expanded_string("", cfg.opts.config))
243                .unwrap_or_else(|| index.to_string());
244            let index_width = string_width(&index_value);
245
246            table.insert((row + 1, 0), index_value);
247            index_column_width = max(index_column_width, index_width);
248        }
249
250        if available_width <= index_column_width + extra_width {
251            // NOTE: we don't wanna wrap index; so we return
252            return Ok(None);
253        }
254
255        available_width -= index_column_width + extra_width;
256        widths.push(index_column_width);
257    }
258
259    let count_columns = headers.len();
260    let mut truncate = false;
261    let mut rendered_column = 0;
262    for (col, header) in headers.into_iter().enumerate() {
263        let column = col + with_index as usize;
264        if available_width <= extra_width {
265            table.pop_column(table.count_columns() - column);
266            truncate = true;
267            break;
268        }
269
270        let mut available = available_width - extra_width;
271
272        // We want to reserver some space for next column
273        // If we can't fit it in it will be popped anyhow.
274        let is_prelast_column = col + 2 == count_columns;
275        let is_last_column = col + 1 == count_columns;
276        if is_prelast_column {
277            let need_width = MIN_CELL_WIDTH + SPLIT_LINE_SPACE;
278            if available > need_width {
279                available -= need_width;
280            }
281        } else if !is_last_column {
282            let need_width: usize = truncate_column_width + SPLIT_LINE_SPACE;
283            if available > need_width {
284                available -= need_width;
285            }
286        }
287
288        let mut total_column_rows = 0usize;
289        let mut column_width = 0;
290
291        for (row, item) in input.iter().enumerate() {
292            cfg.opts.signals.check(cfg.opts.span)?;
293            check_value(item)?;
294
295            let inner_cfg = cfg_expand_reset_table(cfg.clone(), available);
296            let cell = expand_entry_with_header(item, &header, inner_cfg);
297            let value_width = string_width(&cell.text); // TODO: optimize cause when we expand we alrready know the width (most of the time or all)
298
299            column_width = max(column_width, value_width);
300
301            table.insert((row + 1, column), cell.text);
302            table.insert_style((row + 1, column), cell.style);
303
304            total_column_rows = total_column_rows.saturating_add(cell.size);
305        }
306
307        let mut head_width = string_width(&header);
308        let mut header = header;
309        if head_width > available {
310            header = wrap_text(&header, available, cfg.opts.config);
311            head_width = available;
312        }
313
314        table.insert((0, column), header);
315
316        column_width = max(column_width, head_width);
317        assert!(column_width <= available);
318
319        widths.push(column_width);
320
321        available_width -= column_width + extra_width;
322        rendered_column += 1;
323
324        total_rows = std::cmp::max(total_rows, total_column_rows);
325    }
326
327    if truncate {
328        if rendered_column == 0 {
329            // it means that no actual data was rendered, there might be only index present,
330            // so there's no point in rendering the table.
331            //
332            // It's actually quite important in case it's called recursively,
333            // cause we will back up to the basic table view as a string e.g. '[table 123 columns]'.
334            //
335            // But potentially if its reached as a 1st called function we might would love to see the index.
336
337            return Ok(None);
338        }
339
340        if available_width < truncate_column_width {
341            // back up by removing last column.
342            // it's LIKELY that removing only 1 column will leave us enough space for a shift column.
343            while let Some(width) = widths.pop() {
344                table.pop_column(1);
345
346                available_width += width + pad_width;
347                if !widths.is_empty() {
348                    available_width += SPLIT_LINE_SPACE;
349                }
350
351                if available_width > truncate_column_width {
352                    break;
353                }
354            }
355        }
356
357        // this must be a RARE case or even NEVER happen,
358        // but we do check it just in case.
359        if available_width < truncate_column_width {
360            return Ok(None);
361        }
362
363        let is_last_column = widths.len() == count_columns;
364        if !is_last_column {
365            table.push_column(String::from("..."));
366            widths.push(3);
367        }
368    }
369
370    Ok(Some(TableOutput::new(table, true, with_index, total_rows)))
371}
372
373fn expanded_table_kv(record: &Record, cfg: Cfg<'_>) -> CellResult {
374    let theme = load_theme(cfg.opts.mode);
375    let theme = theme.as_base();
376    let key_width = record
377        .columns()
378        .map(|col| string_width(col))
379        .max()
380        .unwrap_or(0);
381    let count_borders = theme.borders_has_vertical() as usize
382        + theme.borders_has_right() as usize
383        + theme.borders_has_left() as usize;
384    let pad = cfg.opts.config.table.padding.left + cfg.opts.config.table.padding.right;
385    if key_width + count_borders + pad + pad > cfg.opts.width {
386        return Ok(None);
387    }
388
389    let value_width = cfg.opts.width - key_width - count_borders - pad - pad;
390
391    let mut total_rows = 0usize;
392
393    let mut table = NuTable::new(record.len(), 2);
394    table.set_index_style(get_key_style(&cfg));
395    table.set_indent(cfg.opts.config.table.padding);
396
397    for (i, (key, value)) in record.iter().enumerate() {
398        cfg.opts.signals.check(cfg.opts.span)?;
399
400        let cell = match expand_value(value, value_width, &cfg)? {
401            Some(val) => val,
402            None => return Ok(None),
403        };
404
405        let value = cell.text;
406        let mut key = key.to_owned();
407
408        // we want to have a key being aligned to 2nd line,
409        // we could use Padding for it but,
410        // the easiest way to do so is just push a new_line char before
411        let is_key_on_next_line = !key.is_empty() && cell.is_expanded && theme.borders_has_top();
412        if is_key_on_next_line {
413            key.insert(0, '\n');
414        }
415
416        table.insert((i, 0), key);
417        table.insert((i, 1), value);
418
419        total_rows = total_rows.saturating_add(cell.size);
420    }
421
422    let mut out = TableOutput::new(table, false, true, total_rows);
423
424    configure_table(
425        &mut out,
426        cfg.opts.config,
427        &cfg.opts.style_computer,
428        cfg.opts.mode,
429    );
430
431    maybe_expand_table(out, cfg.opts.width)
432        .map(|value| value.map(|value| CellOutput::clean(value, total_rows, false)))
433}
434
435// the flag is used as an optimization to not do `value.lines().count()` search.
436fn expand_value(value: &Value, width: usize, cfg: &Cfg<'_>) -> CellResult {
437    if is_limit_reached(cfg) {
438        let value = value_to_string_clean(value, cfg);
439        return Ok(Some(CellOutput::clean(value, 1, false)));
440    }
441
442    let span = value.span();
443    match value {
444        Value::List { vals, .. } => {
445            let inner_cfg = cfg_expand_reset_table(cfg_expand_next_level(cfg.clone(), span), width);
446            let table = expand_list(vals, inner_cfg)?;
447
448            match table {
449                Some(mut out) => {
450                    table_apply_config(&mut out, cfg);
451                    let value = out.table.draw_unchecked(width);
452                    match value {
453                        Some(value) => Ok(Some(CellOutput::clean(value, out.count_rows, true))),
454                        None => Ok(None),
455                    }
456                }
457                None => {
458                    // it means that the list is empty
459                    let value = value_to_wrapped_string(value, cfg, width);
460                    Ok(Some(CellOutput::text(value)))
461                }
462            }
463        }
464        Value::Record { val: record, .. } => {
465            if record.is_empty() {
466                // Like list case return styled string instead of empty value
467                let value = value_to_wrapped_string(value, cfg, width);
468                return Ok(Some(CellOutput::text(value)));
469            }
470
471            let inner_cfg = cfg_expand_reset_table(cfg_expand_next_level(cfg.clone(), span), width);
472            let result = expanded_table_kv(record, inner_cfg)?;
473            match result {
474                Some(result) => Ok(Some(CellOutput::clean(result.text, result.size, true))),
475                None => {
476                    let value = value_to_wrapped_string(value, cfg, width);
477                    Ok(Some(CellOutput::text(value)))
478                }
479            }
480        }
481        _ => {
482            let value = value_to_wrapped_string_clean(value, cfg, width);
483            Ok(Some(CellOutput::text(value)))
484        }
485    }
486}
487
488fn get_key_style(cfg: &Cfg<'_>) -> TextStyle {
489    get_header_style(&cfg.opts.style_computer).alignment(Alignment::Left)
490}
491
492fn expand_entry_with_header(item: &Value, header: &str, cfg: Cfg<'_>) -> CellOutput {
493    match item {
494        Value::Record { val, .. } => match val.get(header) {
495            Some(val) => expand_entry(val, cfg),
496            None => CellOutput::styled(error_sign(
497                cfg.opts.config.table.missing_value_symbol.clone(),
498                &cfg.opts.style_computer,
499            )),
500        },
501        _ => expand_entry(item, cfg),
502    }
503}
504
505fn expand_entry(item: &Value, cfg: Cfg<'_>) -> CellOutput {
506    if is_limit_reached(&cfg) {
507        let value = nu_value_to_string_clean(item, cfg.opts.config, &cfg.opts.style_computer);
508        let value = nutext_wrap(value, &cfg);
509        return CellOutput::styled(value);
510    }
511
512    let span = item.span();
513    match &item {
514        Value::Record { val: record, .. } => {
515            if record.is_empty() {
516                let value = nu_value_to_string(item, cfg.opts.config, &cfg.opts.style_computer);
517                let value = nutext_wrap(value, &cfg);
518                return CellOutput::styled(value);
519            }
520
521            // we verify what is the structure of a Record cause it might represent
522            let inner_cfg = cfg_expand_next_level(cfg.clone(), span);
523            let table = expanded_table_kv(record, inner_cfg);
524
525            match table {
526                Ok(Some(table)) => table,
527                _ => {
528                    let value = nu_value_to_string(item, cfg.opts.config, &cfg.opts.style_computer);
529                    let value = nutext_wrap(value, &cfg);
530                    CellOutput::styled(value)
531                }
532            }
533        }
534        Value::List { vals, .. } => {
535            if cfg.format.flatten && is_simple_list(vals) {
536                let value = list_to_string(
537                    vals,
538                    cfg.opts.config,
539                    &cfg.opts.style_computer,
540                    &cfg.format.flatten_sep,
541                );
542                return CellOutput::text(value);
543            }
544
545            let inner_cfg = cfg_expand_next_level(cfg.clone(), span);
546            let table = expand_list(vals, inner_cfg);
547
548            let mut out = match table {
549                Ok(Some(out)) => out,
550                _ => {
551                    let value = nu_value_to_string(item, cfg.opts.config, &cfg.opts.style_computer);
552                    let value = nutext_wrap(value, &cfg);
553                    return CellOutput::styled(value);
554                }
555            };
556
557            table_apply_config(&mut out, &cfg);
558
559            let table = out.table.draw_unchecked(cfg.opts.width);
560            match table {
561                Some(table) => CellOutput::clean(table, out.count_rows, false),
562                None => {
563                    let value = nu_value_to_string(item, cfg.opts.config, &cfg.opts.style_computer);
564                    let value = nutext_wrap(value, &cfg);
565                    CellOutput::styled(value)
566                }
567            }
568        }
569        _ => {
570            let value = nu_value_to_string_clean(item, cfg.opts.config, &cfg.opts.style_computer);
571            let value = nutext_wrap(value, &cfg);
572            CellOutput::styled(value)
573        }
574    }
575}
576
577fn nutext_wrap(mut text: NuText, cfg: &Cfg<'_>) -> NuText {
578    let width = string_width(&text.0);
579    if width > cfg.opts.width {
580        text.0 = wrap_text(&text.0, cfg.opts.width, cfg.opts.config);
581    }
582
583    text
584}
585
586fn is_limit_reached(cfg: &Cfg<'_>) -> bool {
587    matches!(cfg.format.expand_limit, Some(0))
588}
589
590fn is_simple_list(vals: &[Value]) -> bool {
591    vals.iter()
592        .all(|v| !matches!(v, Value::Record { .. } | Value::List { .. }))
593}
594
595fn list_to_string(
596    vals: &[Value],
597    config: &Config,
598    style_computer: &StyleComputer,
599    sep: &str,
600) -> String {
601    let mut buf = String::new();
602    for (i, value) in vals.iter().enumerate() {
603        if i > 0 {
604            buf.push_str(sep);
605        }
606
607        let (text, _) = nu_value_to_string_clean(value, config, style_computer);
608        buf.push_str(&text);
609    }
610
611    buf
612}
613
614fn maybe_expand_table(mut out: TableOutput, term_width: usize) -> StringResult {
615    let total_width = out.table.total_width();
616    if total_width < term_width {
617        const EXPAND_THRESHOLD: f32 = 0.80;
618        let used_percent = total_width as f32 / term_width as f32;
619        let need_expansion = total_width < term_width && used_percent > EXPAND_THRESHOLD;
620        if need_expansion {
621            out.table.set_strategy(true);
622        }
623    }
624
625    let table = out.table.draw_unchecked(term_width);
626
627    Ok(table)
628}
629
630fn table_apply_config(out: &mut TableOutput, cfg: &Cfg<'_>) {
631    configure_table(
632        out,
633        cfg.opts.config,
634        &cfg.opts.style_computer,
635        cfg.opts.mode,
636    )
637}
638
639fn value_to_string(value: &Value, cfg: &Cfg<'_>) -> String {
640    nu_value_to_string(value, cfg.opts.config, &cfg.opts.style_computer).0
641}
642
643fn value_to_string_clean(value: &Value, cfg: &Cfg<'_>) -> String {
644    nu_value_to_string_clean(value, cfg.opts.config, &cfg.opts.style_computer).0
645}
646
647fn value_to_wrapped_string(value: &Value, cfg: &Cfg<'_>, value_width: usize) -> String {
648    wrap_text(&value_to_string(value, cfg), value_width, cfg.opts.config)
649}
650
651fn value_to_wrapped_string_clean(value: &Value, cfg: &Cfg<'_>, value_width: usize) -> String {
652    let text = nu_value_to_string_colored(value, cfg.opts.config, &cfg.opts.style_computer);
653    wrap_text(&text, value_width, cfg.opts.config)
654}
655
656fn cfg_expand_next_level(mut cfg: Cfg<'_>, span: Span) -> Cfg<'_> {
657    cfg.opts.span = span;
658    if let Some(deep) = cfg.format.expand_limit.as_mut() {
659        *deep -= 1
660    }
661
662    cfg
663}
664
665fn cfg_expand_reset_table(mut cfg: Cfg<'_>, width: usize) -> Cfg<'_> {
666    cfg.opts.width = width;
667    cfg.opts.index_offset = 0;
668    cfg
669}