Skip to main content

r/interpreter/builtins/
tables_display.rs

1//! Rich table display builtins using the `tabled` crate.
2//!
3//! Provides `View()` for interactive data.frame viewing and `kable()` for
4//! markdown-formatted table output. Also exports helpers used by `str()`.
5
6use crate::interpreter::value::*;
7use crate::interpreter::BuiltinContext;
8use minir_macros::{builtin, interpreter_builtin};
9use tabled::settings::style::Style;
10use tabled::settings::{Modify, Width};
11use tabled::{builder::Builder, settings::object::Columns};
12
13use super::has_class;
14
15// region: data frame extraction helpers
16
17/// Extracted table data from an R data.frame (or matrix).
18struct TableData {
19    col_names: Vec<String>,
20    col_types: Vec<&'static str>,
21    row_names: Vec<String>,
22    /// Each inner Vec is one column's formatted values.
23    columns: Vec<Vec<String>>,
24    nrow: usize,
25}
26
27/// Extract table data from an RValue that is a data.frame.
28///
29/// Returns `None` if the value is not a data.frame list.
30fn extract_data_frame(val: &RValue) -> Option<TableData> {
31    let list = match val {
32        RValue::List(l) if has_class(val, "data.frame") => l,
33        _ => return None,
34    };
35
36    if list.values.is_empty() {
37        return Some(TableData {
38            col_names: Vec::new(),
39            col_types: Vec::new(),
40            row_names: Vec::new(),
41            columns: Vec::new(),
42            nrow: 0,
43        });
44    }
45
46    let col_names: Vec<String> = list
47        .values
48        .iter()
49        .enumerate()
50        .map(|(i, (name, _))| name.clone().unwrap_or_else(|| format!("V{}", i + 1)))
51        .collect();
52
53    let nrow = list
54        .get_attr("row.names")
55        .map(|v| v.length())
56        .unwrap_or_else(|| list.values.first().map(|(_, v)| v.length()).unwrap_or(0));
57
58    let row_names: Vec<String> = match list.get_attr("row.names") {
59        Some(RValue::Vector(rv)) => match &rv.inner {
60            Vector::Character(chars) => chars
61                .iter()
62                .map(|c| c.clone().unwrap_or_else(|| "NA".to_string()))
63                .collect(),
64            Vector::Integer(ints) => ints
65                .iter()
66                .map(|i| match i {
67                    Some(v) => v.to_string(),
68                    None => "NA".to_string(),
69                })
70                .collect(),
71            _ => (1..=nrow).map(|i| i.to_string()).collect(),
72        },
73        _ => (1..=nrow).map(|i| i.to_string()).collect(),
74    };
75
76    let col_types: Vec<&'static str> = list
77        .values
78        .iter()
79        .map(|(_, value)| match value {
80            RValue::Vector(rv) => rv.inner.type_name(),
81            RValue::Null => "NULL",
82            _ => "list",
83        })
84        .collect();
85
86    let columns: Vec<Vec<String>> = list
87        .values
88        .iter()
89        .map(|(_, value)| match value {
90            RValue::Vector(rv) => format_column_values(&rv.inner, nrow),
91            RValue::Null => vec!["NULL".to_string(); nrow],
92            other => vec![format!("{}", other); nrow],
93        })
94        .collect();
95
96    Some(TableData {
97        col_names,
98        col_types,
99        row_names,
100        columns,
101        nrow,
102    })
103}
104
105/// Format individual elements of a vector column.
106fn format_column_values(v: &Vector, nrow: usize) -> Vec<String> {
107    use crate::interpreter::value::vector::{format_r_complex, format_r_double};
108
109    let len = v.len();
110    (0..nrow)
111        .map(|i| {
112            if i >= len {
113                return "NA".to_string();
114            }
115            match v {
116                Vector::Raw(vals) => format!("{:02x}", vals[i]),
117                Vector::Logical(vals) => match vals[i] {
118                    Some(true) => "TRUE".to_string(),
119                    Some(false) => "FALSE".to_string(),
120                    None => "NA".to_string(),
121                },
122                Vector::Integer(vals) => match vals.get_opt(i) {
123                    Some(n) => n.to_string(),
124                    None => "NA".to_string(),
125                },
126                Vector::Double(vals) => match vals.get_opt(i) {
127                    Some(f) => format_r_double(f),
128                    None => "NA".to_string(),
129                },
130                Vector::Complex(vals) => match vals[i] {
131                    Some(c) => format_r_complex(c),
132                    None => "NA".to_string(),
133                },
134                Vector::Character(vals) => match &vals[i] {
135                    Some(s) => s.clone(),
136                    None => "NA".to_string(),
137                },
138            }
139        })
140        .collect()
141}
142
143// endregion
144
145// region: View()
146
147/// Display a data.frame as a nicely formatted table.
148///
149/// Terminal equivalent of RStudio's View pane. Shows column headers with types,
150/// row numbers, and truncates wide columns (max 30 chars) and long data frames
151/// (first 20 rows + "... N more rows" footer).
152///
153/// @param x a data.frame to display
154/// @param title optional title (unused in terminal mode)
155/// @return x, invisibly
156#[interpreter_builtin(name = "View", min_args = 1)]
157fn interp_view(
158    args: &[RValue],
159    _named: &[(String, RValue)],
160    context: &BuiltinContext,
161) -> Result<RValue, RError> {
162    let val = &args[0];
163
164    let data = extract_data_frame(val).ok_or_else(|| {
165        RError::new(
166            RErrorKind::Argument,
167            "View() requires a data.frame. Use as.data.frame() to convert other objects."
168                .to_string(),
169        )
170    })?;
171
172    // If a GUI channel is available, send the data as a View tab
173    #[cfg(feature = "plot")]
174    {
175        let tx = context.interpreter().plot_tx.borrow();
176        if let Some(tx) = tx.as_ref() {
177            use crate::interpreter::graphics::view::ColType;
178            let table_data = crate::interpreter::graphics::view::TableData {
179                title: "View".to_string(),
180                headers: data.col_names.clone(),
181                col_types: data
182                    .col_types
183                    .iter()
184                    .map(|t| match *t {
185                        "dbl" => ColType::Double,
186                        "int" => ColType::Integer,
187                        "chr" => ColType::Character,
188                        "lgl" => ColType::Logical,
189                        _ => ColType::Other,
190                    })
191                    .collect(),
192                row_names: data.row_names.clone(),
193                rows: {
194                    let nrow = data.nrow;
195                    let ncol = data.columns.len();
196                    (0..nrow)
197                        .map(|r| {
198                            (0..ncol)
199                                .map(|c| {
200                                    data.columns
201                                        .get(c)
202                                        .and_then(|col| col.get(r).cloned())
203                                        .unwrap_or_else(|| "NA".to_string())
204                                })
205                                .collect()
206                        })
207                        .collect()
208                },
209            };
210            let _ =
211                tx.send(crate::interpreter::graphics::egui_device::PlotMessage::View(table_data));
212            context.interpreter().set_invisible();
213            return Ok(val.clone());
214        }
215    }
216
217    if data.nrow == 0 {
218        context.write(&format!(
219            "data frame with 0 rows and {} columns: {}\n",
220            data.col_names.len(),
221            data.col_names.join(", ")
222        ));
223        return Ok(val.clone());
224    }
225
226    let max_display_rows: usize = 20;
227    let max_col_width: usize = 30;
228    let display_rows = data.nrow.min(max_display_rows);
229
230    // Build header: column name <type>
231    let headers: Vec<String> = std::iter::once(String::new()) // row-name column
232        .chain(
233            data.col_names
234                .iter()
235                .zip(data.col_types.iter())
236                .map(|(name, ty)| format!("{} <{}>", name, short_type_name(ty))),
237        )
238        .collect();
239
240    let mut builder = Builder::new();
241    builder.push_record(&headers);
242
243    for row in 0..display_rows {
244        let row_name = data
245            .row_names
246            .get(row)
247            .cloned()
248            .unwrap_or_else(|| (row + 1).to_string());
249        let mut cells: Vec<String> = vec![row_name];
250        for col in &data.columns {
251            cells.push(col.get(row).cloned().unwrap_or_else(|| "NA".to_string()));
252        }
253        builder.push_record(&cells);
254    }
255
256    let mut table = builder.build();
257    table
258        .with(Style::rounded())
259        .with(Modify::new(Columns::new(1..)).with(Width::truncate(max_col_width).suffix("...")));
260
261    context.write(&format!("{}\n", table));
262
263    if data.nrow > max_display_rows {
264        context.write(&format!(
265            "... {} more rows ({} total)\n",
266            data.nrow - max_display_rows,
267            data.nrow
268        ));
269    }
270
271    Ok(val.clone())
272}
273
274// endregion
275
276// region: kable()
277
278/// Render a data.frame as a markdown/pipe table.
279///
280/// Simplified version of `knitr::kable(x, format = "pipe")`. Produces a
281/// markdown-formatted table suitable for inclusion in reports.
282///
283/// @param x a data.frame
284/// @param format table format: "pipe" (default) or "simple"
285/// @return character string containing the formatted table
286#[builtin(min_args = 1)]
287fn builtin_kable(args: &[RValue], named: &[(String, RValue)]) -> Result<RValue, RError> {
288    let val = &args[0];
289
290    let data = extract_data_frame(val).ok_or_else(|| {
291        RError::new(
292            RErrorKind::Argument,
293            "kable() requires a data.frame. Use as.data.frame() to convert other objects."
294                .to_string(),
295        )
296    })?;
297
298    // Parse format argument (default: "pipe" = markdown)
299    let format_owned = named
300        .iter()
301        .find(|(n, _)| n == "format")
302        .and_then(|(_, v)| v.as_vector())
303        .and_then(|v| v.as_character_scalar());
304    let format = format_owned.as_deref().unwrap_or("pipe");
305
306    if data.nrow == 0 {
307        let header = data.col_names.join(" | ");
308        return Ok(RValue::vec(Vector::Character(vec![Some(header)].into())));
309    }
310
311    // Build the table
312    let headers: Vec<String> = data.col_names.clone();
313
314    let mut builder = Builder::new();
315    builder.push_record(&headers);
316
317    for row in 0..data.nrow {
318        let cells: Vec<String> = data
319            .columns
320            .iter()
321            .map(|col| col.get(row).cloned().unwrap_or_else(|| "NA".to_string()))
322            .collect();
323        builder.push_record(&cells);
324    }
325
326    let mut table = builder.build();
327
328    match format {
329        "pipe" | "markdown" => {
330            table.with(Style::markdown());
331        }
332        "simple" => {
333            table.with(Style::psql());
334        }
335        _ => {
336            table.with(Style::markdown());
337        }
338    }
339
340    let output = table.to_string();
341    Ok(RValue::vec(Vector::Character(vec![Some(output)].into())))
342}
343
344// endregion
345
346// region: str() data.frame helper
347
348/// Format `str()` output for a data.frame using tabled for alignment.
349///
350/// Produces output like R's `str()`:
351/// ```text
352/// 'data.frame':  N obs. of  M variables:
353///  $ col1: int  1 2 3 ...
354///  $ col2: chr  "a" "b" "c" ...
355/// ```
356pub(crate) fn str_data_frame(val: &RValue) -> Option<String> {
357    let data = extract_data_frame(val)?;
358
359    let mut out = String::new();
360    out.push_str(&format!(
361        "'data.frame':\t{} obs. of  {} variables:\n",
362        data.nrow,
363        data.col_names.len()
364    ));
365
366    let max_preview = 10;
367
368    // Build rows for each column: "$ name : type  preview..."
369    let mut builder = Builder::new();
370
371    for (i, col_name) in data.col_names.iter().enumerate() {
372        let ty = data.col_types[i];
373        let short_ty = short_type_name(ty);
374
375        // Build preview of first N elements
376        let preview: String = data.columns[i]
377            .iter()
378            .take(max_preview)
379            .map(|val| {
380                if ty == "character" {
381                    format!("\"{}\"", val)
382                } else {
383                    val.clone()
384                }
385            })
386            .collect::<Vec<_>>()
387            .join(" ");
388
389        let ellipsis = if data.nrow > max_preview { " ..." } else { "" };
390
391        builder.push_record([
392            format!(" $ {}", col_name),
393            format!(": {}", short_ty),
394            format!(" {}{}", preview, ellipsis),
395        ]);
396    }
397
398    let mut table = builder.build();
399    table.with(Style::empty());
400
401    out.push_str(&table.to_string());
402    Some(out)
403}
404
405/// Map R type names to short abbreviations for display.
406fn short_type_name(ty: &str) -> &str {
407    match ty {
408        "integer" => "int",
409        "double" => "num",
410        "character" => "chr",
411        "logical" => "lgl",
412        "complex" => "cpl",
413        "raw" => "raw",
414        "NULL" => "NULL",
415        "list" => "list",
416        _ => ty,
417    }
418}
419
420// endregion