use inillucent_value::Value;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Mode {
#[default]
List,
Column,
Line,
Csv,
Tabs,
Quote,
Insert,
Json,
Markdown,
Table,
Box,
Html,
}
impl Mode {
pub fn from_name(name: &str) -> Option<Mode> {
match name.to_ascii_lowercase().as_str() {
"list" => Some(Mode::List),
"column" | "columns" => Some(Mode::Column),
"line" | "lines" => Some(Mode::Line),
"csv" => Some(Mode::Csv),
"tabs" => Some(Mode::Tabs),
"quote" => Some(Mode::Quote),
"insert" => Some(Mode::Insert),
"json" => Some(Mode::Json),
"markdown" => Some(Mode::Markdown),
"table" => Some(Mode::Table),
"box" => Some(Mode::Box),
"html" => Some(Mode::Html),
_ => None,
}
}
pub fn separator(self) -> &'static str {
match self {
Mode::Csv | Mode::Quote => ",",
Mode::Tabs => "\t",
_ => "|",
}
}
pub fn name(self) -> &'static str {
match self {
Mode::List => "list",
Mode::Column => "column",
Mode::Line => "line",
Mode::Csv => "csv",
Mode::Tabs => "tabs",
Mode::Quote => "quote",
Mode::Insert => "insert",
Mode::Json => "json",
Mode::Markdown => "markdown",
Mode::Table => "table",
Mode::Box => "box",
Mode::Html => "html",
}
}
}
#[derive(Clone, Debug)]
pub struct Layout {
pub mode: Mode,
pub separator: String,
pub row_separator: String,
pub null: String,
pub headers: bool,
pub table: String,
pub widths: Vec<usize>,
pub to_stdout: bool,
}
impl Default for Layout {
fn default() -> Layout {
Layout {
mode: Mode::List,
separator: "|".to_string(),
row_separator: "\n".to_string(),
null: String::new(),
headers: false,
table: "tab".to_string(),
widths: Vec::new(),
to_stdout: true,
}
}
}
pub fn render(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
match layout.mode {
Mode::List | Mode::Tabs => separated(layout, columns, rows),
Mode::Csv => csv(layout, columns, rows),
Mode::Quote => quoted(layout, columns, rows),
Mode::Line => lines(layout, columns, rows),
Mode::Insert => inserts(layout, columns, rows),
Mode::Json => json(layout, columns, rows),
Mode::Column => aligned(layout, columns, rows),
Mode::Markdown | Mode::Table | Mode::Box => drawn(layout, columns, rows),
Mode::Html => html(layout, columns, rows),
}
}
fn plain(layout: &Layout, value: &Value<'static>) -> String {
match value {
Value::Null => layout.null.clone(),
Value::Integer(number) => number.to_string(),
Value::Real(_) => number_text(value),
Value::Text(text) => printable(text.raw()),
Value::Blob(blob) => printable(blob.raw()),
}
}
fn printable(bytes: &[u8]) -> String {
let end = bytes
.iter()
.position(|byte| *byte == 0)
.unwrap_or(bytes.len());
let visible = bytes.get(..end).unwrap_or(bytes);
let mut out = String::with_capacity(visible.len());
for chunk in String::from_utf8_lossy(visible).chars() {
let code = chunk as u32;
if chunk == '\n' {
out.push(chunk);
} else if code < 0x20 {
out.push('^');
out.push(char::from_u32(code + 0x40).unwrap_or('?'));
} else if code == 0x7f {
out.push_str("^?");
} else {
out.push(chunk);
}
}
out
}
fn number_text(value: &Value<'static>) -> String {
let cast = inillucent_value::cast::cast_value(
value.clone(),
inillucent_value::Affinity::Text,
inillucent_value::TextEncoding::Utf8,
);
match cast {
Ok(Value::Text(text)) => String::from_utf8_lossy(text.raw()).into_owned(),
_ => String::new(),
}
}
pub fn literal(value: &Value<'static>) -> String {
match value {
Value::Null => "NULL".to_string(),
Value::Integer(number) => number.to_string(),
Value::Real(_) => number_text(value),
Value::Text(text) => {
let body = String::from_utf8_lossy(text.raw()).replace('\'', "''");
format!("'{body}'")
}
Value::Blob(blob) => {
let mut out = String::from("x'");
for byte in blob.raw() {
out.push_str(&format!("{byte:02x}"));
}
out.push('\'');
out
}
}
}
fn separated(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
let separator = if layout.mode == Mode::Tabs {
"\t"
} else {
layout.separator.as_str()
};
let mut out = Vec::with_capacity(rows.len() + 1);
if layout.headers {
out.push(columns.join(separator));
}
for row in rows {
let cells: Vec<String> = row.iter().map(|value| plain(layout, value)).collect();
out.push(cells.join(separator));
}
out
}
fn csv(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
let mut out = Vec::with_capacity(rows.len() + 1);
if layout.headers {
out.push(
columns
.iter()
.map(|name| csv_field(name))
.collect::<Vec<String>>()
.join(","),
);
}
for row in rows {
let cells: Vec<String> = row.iter().map(|value| csv_cell(layout, value)).collect();
out.push(cells.join(","));
}
if layout.row_separator.ends_with(CRLF) {
for line in &mut out {
line.push(CR);
if cfg!(windows) && layout.to_stdout {
line.push(CR);
}
}
}
out
}
const CRLF: &str = "\r\n";
const CR: char = '\r';
fn csv_cell(layout: &Layout, value: &Value<'static>) -> String {
let text = plain(layout, value);
if text.is_empty() && !value.is_null() {
return "\"\"".to_string();
}
csv_field(&text)
}
fn csv_field(text: &str) -> String {
let needs = text.contains(',')
|| text.contains('"')
|| text.contains('\n')
|| text.contains('\r')
|| text.contains('^');
if !needs {
return text.to_string();
}
format!("\"{}\"", text.replace('"', "\"\""))
}
fn quoted(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
let mut out = Vec::with_capacity(rows.len() + 1);
if layout.headers {
out.push(
columns
.iter()
.map(|name| format!("'{}'", name.replace('\'', "''")))
.collect::<Vec<String>>()
.join(&layout.separator),
);
}
for row in rows {
let cells: Vec<String> = row.iter().map(literal).collect();
out.push(cells.join(&layout.separator));
}
out
}
fn lines(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
let width = columns
.iter()
.map(|name| name.chars().count())
.max()
.unwrap_or(0);
let mut out = Vec::new();
for (index, row) in rows.iter().enumerate() {
if index > 0 {
out.push(String::new());
}
for (position, value) in row.iter().enumerate() {
let name = columns.get(position).cloned().unwrap_or_default();
out.push(format!("{name:>width$}: {}", plain(layout, value)));
}
}
out
}
fn inserts(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
let _ = columns;
rows.iter()
.map(|row| {
let cells: Vec<String> = row.iter().map(literal).collect();
format!("INSERT INTO {} VALUES({});", layout.table, cells.join(","))
})
.collect()
}
fn json(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
let _ = layout;
if rows.is_empty() {
return Vec::new();
}
let mut out = Vec::with_capacity(rows.len());
for (index, row) in rows.iter().enumerate() {
let members: Vec<String> = row
.iter()
.enumerate()
.map(|(position, value)| {
let name = columns.get(position).cloned().unwrap_or_default();
format!(
"\"{}\":{}",
inillucent_base::json::escape(&name),
json_value(value)
)
})
.collect();
let open = if index == 0 { "[" } else { "" };
let close = if index + 1 == rows.len() { "]" } else { "," };
out.push(format!("{open}{{{}}}{close}", members.join(",")));
}
out
}
fn json_value(value: &Value<'static>) -> String {
match value {
Value::Null => "null".to_string(),
Value::Integer(number) => number.to_string(),
Value::Real(_) => number_text(value),
Value::Text(text) => format!(
"\"{}\"",
inillucent_base::json::escape(&String::from_utf8_lossy(text.raw()))
),
Value::Blob(blob) => {
let escaped: String = blob
.raw()
.iter()
.map(|byte| format!("\\u{byte:04x}"))
.collect();
format!("\"{escaped}\"")
}
}
}
fn widths(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<usize> {
let mut widths: Vec<usize> = columns.iter().map(|name| name.chars().count()).collect();
for row in rows {
for (index, value) in row.iter().enumerate() {
let width = plain(layout, value).chars().count();
match widths.get_mut(index) {
Some(existing) => *existing = (*existing).max(width),
None => widths.push(width),
}
}
}
for (index, fixed) in layout.widths.iter().enumerate() {
if *fixed == 0 {
continue;
}
if let Some(existing) = widths.get_mut(index) {
*existing = *fixed;
}
}
widths
}
fn aligned(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
let widths = widths(layout, columns, rows);
let mut out = Vec::with_capacity(rows.len() + 2);
if layout.headers {
let centred: Vec<String> = columns
.iter()
.enumerate()
.map(|(index, name)| centre(name, widths.get(index).copied().unwrap_or(0)))
.collect();
out.push(centred.join(" ").trim_end().to_string());
out.push(
widths
.iter()
.map(|width| "-".repeat(*width))
.collect::<Vec<String>>()
.join(" "),
);
}
for row in rows {
let cells: Vec<String> = row
.iter()
.enumerate()
.map(|(index, value)| align(layout, value, widths.get(index).copied().unwrap_or(0)))
.collect();
out.push(pad_row(&cells, &widths));
}
out
}
fn align(layout: &Layout, value: &Value<'static>, width: usize) -> String {
let text = plain(layout, value);
if matches!(value, Value::Integer(_) | Value::Real(_)) {
return format!("{text:>width$}");
}
text
}
fn centre(text: &str, width: usize) -> String {
let length = text.chars().count();
if length >= width {
return text.to_string();
}
let left = (width - length) / 2;
let right = width - length - left;
format!("{}{text}{}", " ".repeat(left), " ".repeat(right))
}
fn pad_row(cells: &[String], widths: &[usize]) -> String {
let padded: Vec<String> = cells
.iter()
.enumerate()
.map(|(index, cell)| {
let width = widths.get(index).copied().unwrap_or(0);
format!("{cell:<width$}")
})
.collect();
padded.join(" ").trim_end().to_string()
}
struct Frame {
left: &'static str,
middle: &'static str,
right: &'static str,
horizontal: &'static str,
vertical: &'static str,
}
fn drawn(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
let widths = widths(layout, columns, rows);
let frame = match layout.mode {
Mode::Markdown => Frame {
left: "|",
middle: "|",
right: "|",
horizontal: "-",
vertical: "|",
},
Mode::Box => Frame {
left: "\u{256d}",
middle: "\u{252c}",
right: "\u{256e}",
horizontal: "\u{2500}",
vertical: "\u{2502}",
},
_ => Frame {
left: "+",
middle: "+",
right: "+",
horizontal: "-",
vertical: "|",
},
};
let mut out = Vec::with_capacity(rows.len() + 4);
let rule = rule_line(&frame, &widths);
if layout.mode != Mode::Markdown {
out.push(rule.clone());
}
let centred: Vec<String> = columns
.iter()
.enumerate()
.map(|(index, name)| centre(name, widths.get(index).copied().unwrap_or(0)))
.collect();
out.push(drawn_row(&frame, ¢red, &widths, layout, true));
out.push(match layout.mode {
Mode::Markdown => markdown_rule(&widths),
Mode::Box => rule_line(
&Frame {
left: "\u{255e}",
middle: "\u{256a}",
right: "\u{2561}",
horizontal: "\u{2550}",
..frame
},
&widths,
),
_ => rule.clone(),
});
for row in rows {
let cells: Vec<String> = row
.iter()
.enumerate()
.map(|(index, value)| align(layout, value, widths.get(index).copied().unwrap_or(0)))
.collect();
out.push(drawn_row(&frame, &cells, &widths, layout, false));
}
if layout.mode == Mode::Box {
out.push(rule_line(
&Frame {
left: "\u{2570}",
middle: "\u{2534}",
right: "\u{256f}",
..frame
},
&widths,
));
} else if layout.mode != Mode::Markdown {
out.push(rule);
}
out
}
fn rule_line(frame: &Frame, widths: &[usize]) -> String {
let parts: Vec<String> = widths
.iter()
.map(|width| frame.horizontal.repeat(width + 2))
.collect();
format!("{}{}{}", frame.left, parts.join(frame.middle), frame.right)
}
fn markdown_rule(widths: &[usize]) -> String {
let parts: Vec<String> = widths.iter().map(|width| "-".repeat(width + 2)).collect();
format!("|{}|", parts.join("|"))
}
fn drawn_row(
frame: &Frame,
cells: &[String],
widths: &[usize],
layout: &Layout,
header: bool,
) -> String {
let _ = (layout, header);
let padded: Vec<String> = widths
.iter()
.enumerate()
.map(|(index, width)| {
let cell = cells.get(index).cloned().unwrap_or_default();
format!(" {cell:<width$} ")
})
.collect();
format!(
"{}{}{}",
frame.vertical,
padded.join(frame.vertical),
frame.vertical
)
}
fn html(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
let mut out = Vec::new();
if layout.headers {
out.push("<TR>".to_string());
for name in columns {
out.push(format!("<TH>{}", html_escape(name)));
}
out.push("</TR>".to_string());
}
for row in rows {
out.push("<TR>".to_string());
for value in row {
out.push(format!("<TD>{}", html_escape(&plain(layout, value))));
}
out.push("</TR>".to_string());
}
out
}
fn html_escape(text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> (Vec<String>, Vec<Vec<Value<'static>>>) {
let columns = vec!["a".to_string(), "b".to_string()];
let rows = vec![vec![
Value::Integer(1),
Value::owned_text(b"two").expect("owns"),
]];
(columns, rows)
}
#[test]
fn the_default_is_a_pipe_separated_line() {
let (columns, rows) = sample();
let out = render(&Layout::default(), &columns, &rows);
assert_eq!(out, vec!["1|two"]);
}
#[test]
fn headers_use_the_same_layout() {
let (columns, rows) = sample();
let layout = Layout {
headers: true,
..Layout::default()
};
let out = render(&layout, &columns, &rows);
assert_eq!(out, vec!["a|b", "1|two"]);
}
#[test]
fn a_csv_record_ends_the_way_its_destination_expects() {
let (columns, rows) = sample();
let to_a_file = Layout {
mode: Mode::Csv,
separator: ",".to_string(),
row_separator: "\r\n".to_string(),
to_stdout: false,
..Layout::default()
};
assert_eq!(render(&to_a_file, &columns, &rows), vec!["1,two\r"]);
let to_the_terminal = Layout {
to_stdout: true,
..to_a_file
};
let expected = if cfg!(windows) {
"1,two\r\r"
} else {
"1,two\r"
};
assert_eq!(render(&to_the_terminal, &columns, &rows), vec![expected]);
}
#[test]
fn csv_quotes_only_what_it_must() {
assert_eq!(csv_field("plain"), "plain");
assert_eq!(csv_field("a,b"), "\"a,b\"");
assert_eq!(csv_field("say \"hi\""), "\"say \"\"hi\"\"\"");
}
#[test]
fn csv_tells_an_empty_value_from_a_null() {
let columns = vec!["x".to_string()];
let rows = vec![
vec![Value::Null],
vec![Value::owned_blob(&[0, 1, 2]).expect("owns")],
vec![Value::owned_text(b"").expect("owns")],
vec![Value::owned_text(b"kept").expect("owns")],
];
let layout = Layout {
mode: Mode::Csv,
separator: ",".to_string(),
..Layout::default()
};
assert_eq!(
render(&layout, &columns, &rows),
vec!["", "\"\"", "\"\"", "kept"]
);
}
#[test]
fn control_characters_are_escaped() {
assert_eq!(printable(b"ab"), "ab");
assert_eq!(printable(&[0x01, 0x02]), "^A^B");
assert_eq!(printable(&[0x09, b't']), "^It");
assert_eq!(printable(&[0x7f]), "^?");
assert_eq!(printable(b"a\nb"), "a\nb");
assert_eq!(printable(&[b'a', 0, b'b']), "a");
}
#[test]
fn quote_mode_writes_literals() {
let columns = vec!["x".to_string()];
let rows = vec![
vec![Value::Null],
vec![Value::owned_blob(&[1, 255]).expect("owns")],
vec![Value::owned_text(b"it's").expect("owns")],
];
let layout = Layout {
mode: Mode::Quote,
..Layout::default()
};
let out = render(&layout, &columns, &rows);
assert_eq!(out, vec!["NULL", "x'01ff'", "'it''s'"]);
}
#[test]
fn every_mode_name_round_trips() {
for mode in [
Mode::List,
Mode::Column,
Mode::Line,
Mode::Csv,
Mode::Tabs,
Mode::Quote,
Mode::Insert,
Mode::Json,
Mode::Markdown,
Mode::Table,
Mode::Box,
Mode::Html,
] {
assert_eq!(Mode::from_name(mode.name()), Some(mode), "{}", mode.name());
}
assert_eq!(Mode::from_name("nonsense"), None);
}
#[test]
fn a_table_is_drawn_with_rules() {
let (columns, rows) = sample();
let layout = Layout {
mode: Mode::Table,
headers: true,
..Layout::default()
};
let out = render(&layout, &columns, &rows);
assert_eq!(out.len(), 5, "{out:#?}");
assert!(out.first().is_some_and(|line| line.starts_with('+')));
assert!(out.last().is_some_and(|line| line.starts_with('+')));
}
#[test]
fn the_shell_escapes_through_the_base_crate() {
assert_eq!(inillucent_base::json::escape("a\"b"), "a\\\"b");
assert_eq!(inillucent_base::json::escape("a\nb"), "a\\nb");
assert_eq!(inillucent_base::json::escape("a\u{1}b"), "a\\u0001b");
assert_eq!(inillucent_base::json::escape("a\u{8}b"), "a\\bb");
assert_eq!(inillucent_base::json::escape("a\u{c}b"), "a\\fb");
assert_eq!(inillucent_base::json::escape("a\u{7f}b"), "a\\u007fb");
}
}