Skip to main content

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