use std::fmt::Write as _;
use rudb::{LogicalType, QueryResult, Value};
const MAX_ROWS: usize = 40;
const ELIDED: usize = 3;
const LINE_NAME_WIDTH: usize = 5;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Format {
#[default]
DuckBox,
Box,
Table,
Markdown,
Line,
List,
Csv,
Tsv,
Json,
JsonLines,
Quote,
Insert,
Html,
Ascii,
Column,
Trash,
}
impl Format {
pub fn from_name(name: &str) -> Option<Self> {
Some(match name {
"duckbox" => Self::DuckBox,
"box" => Self::Box,
"table" => Self::Table,
"markdown" => Self::Markdown,
"line" | "lines" => Self::Line,
"list" => Self::List,
"csv" => Self::Csv,
"tabs" | "tsv" => Self::Tsv,
"json" => Self::Json,
"jsonlines" | "ndjson" => Self::JsonLines,
"quote" => Self::Quote,
"insert" => Self::Insert,
"html" => Self::Html,
"ascii" => Self::Ascii,
"column" => Self::Column,
"trash" => Self::Trash,
_ => return None,
})
}
pub fn from_flag(name: &str) -> Option<Self> {
match name {
"ascii" | "box" | "column" | "csv" | "html" | "json" | "jsonlines" | "line"
| "list" | "markdown" | "quote" | "table" => Self::from_name(name),
_ => None,
}
}
pub fn name(self) -> &'static str {
match self {
Self::DuckBox => "duckbox",
Self::Box => "box",
Self::Table => "table",
Self::Markdown => "markdown",
Self::Line => "line",
Self::List => "list",
Self::Csv => "csv",
Self::Tsv => "tabs",
Self::Json => "json",
Self::JsonLines => "jsonlines",
Self::Quote => "quote",
Self::Insert => "insert",
Self::Html => "html",
Self::Ascii => "ascii",
Self::Column => "column",
Self::Trash => "trash",
}
}
fn separator(self) -> &'static str {
match self {
Self::Csv | Self::Quote => ",",
Self::Tsv => "\t",
Self::Ascii => "\u{1f}",
_ => "|",
}
}
fn newline(self) -> &'static str {
match self {
Self::Csv => "\r\n",
Self::Ascii => "\u{1e}",
_ => "\n",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Settings {
pub format: Format,
pub header: bool,
pub separator: String,
pub newline: String,
pub nullvalue: String,
pub table: String,
}
impl Default for Settings {
fn default() -> Self {
Self {
format: Format::DuckBox,
header: true,
separator: "|".to_string(),
newline: "\n".to_string(),
nullvalue: "NULL".to_string(),
table: "table".to_string(),
}
}
}
impl Settings {
pub fn set_format(&mut self, format: Format) {
self.format = format;
self.separator = format.separator().to_string();
self.newline = format.newline().to_string();
}
pub fn set_format_flag(&mut self, format: Format) {
self.format = format;
match format {
Format::Ascii => {
self.separator = format.separator().to_string();
self.newline = format.newline().to_string();
}
Format::Csv => self.separator = format.separator().to_string(),
_ => {}
}
}
}
pub fn escaped(text: &str) -> String {
let mut out = String::new();
for character in text.chars() {
match character {
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
'\\' => out.push_str("\\\\"),
other if (other as u32) < 0x20 => {
let _ = write!(out, "\\{:03o}", other as u32);
}
other => out.push(other),
}
}
out
}
pub fn render(result: &QueryResult, settings: &Settings) -> String {
if result.width() == 0 {
return String::new();
}
let cells = cells(result, settings);
match settings.format {
Format::DuckBox => duckbox(result, &cells),
Format::Box => boxed(result, &cells, BOX_GLYPHS),
Format::Table => boxed(result, &cells, TABLE_GLYPHS),
Format::Markdown => markdown(result, &cells),
Format::Line => line(result, &cells),
Format::List | Format::Csv | Format::Tsv => separated(result, &cells, settings),
Format::Json => json(result, settings, true),
Format::JsonLines => json(result, settings, false),
Format::Quote => quote(result, settings),
Format::Insert => insert(result, settings),
Format::Html => html(result, &cells, settings),
Format::Ascii => ascii(result, &cells, settings),
Format::Column => column(result, &cells),
Format::Trash => String::new(),
}
}
fn cells(result: &QueryResult, settings: &Settings) -> Vec<Vec<String>> {
(0..result.len())
.map(|row| {
(0..result.width())
.map(|column| cell(&result.value_at(row, column), settings))
.collect()
})
.collect()
}
fn cell(value: &Value, settings: &Settings) -> String {
match value {
Value::Null => settings.nullvalue.clone(),
other => other.to_string(),
}
}
fn type_name(ty: &LogicalType) -> String {
match ty {
LogicalType::Null => "\"NULL\"".to_string(),
LogicalType::Boolean => "boolean".to_string(),
LogicalType::TinyInt => "int8".to_string(),
LogicalType::SmallInt => "int16".to_string(),
LogicalType::Integer => "int32".to_string(),
LogicalType::BigInt => "int64".to_string(),
LogicalType::HugeInt => "int128".to_string(),
LogicalType::UTinyInt => "uint8".to_string(),
LogicalType::USmallInt => "uint16".to_string(),
LogicalType::UInteger => "uint32".to_string(),
LogicalType::UBigInt => "uint64".to_string(),
LogicalType::UHugeInt => "uint128".to_string(),
LogicalType::Float => "float".to_string(),
LogicalType::Double => "double".to_string(),
LogicalType::Decimal { width, scale } => format!("decimal({width},{scale})"),
LogicalType::Varchar => "varchar".to_string(),
LogicalType::Blob => "blob".to_string(),
LogicalType::Bit => "bit".to_string(),
LogicalType::Uuid => "uuid".to_string(),
LogicalType::Date => "date".to_string(),
LogicalType::Time => "time".to_string(),
LogicalType::TimeTz => "time with time zone".to_string(),
LogicalType::Timestamp => "timestamp".to_string(),
LogicalType::TimestampS => "timestamp_s".to_string(),
LogicalType::TimestampMs => "timestamp_ms".to_string(),
LogicalType::TimestampNs => "timestamp_ns".to_string(),
LogicalType::TimestampTz => "timestamp with time zone".to_string(),
LogicalType::Interval => "interval".to_string(),
LogicalType::List(inner) | LogicalType::Array(inner, _) => {
format!("{}[]", type_name(inner))
}
LogicalType::Map(key, value) => format!("map({}, {})", type_name(key), type_name(value)),
LogicalType::Struct(fields) => {
let inner: Vec<String> =
fields.iter().map(|field| format!("{} {}", field.name, field.ty)).collect();
format!("struct({})", inner.join(", ")).to_lowercase()
}
LogicalType::Union(fields) => {
let inner: Vec<String> =
fields.iter().map(|field| format!("{} {}", field.name, field.ty)).collect();
format!("union({})", inner.join(", ")).to_lowercase()
}
other => other.to_string().to_lowercase(),
}
}
fn width(text: &str) -> usize {
text.chars().count()
}
fn shown(rows: usize) -> Option<(usize, usize)> {
if rows > MAX_ROWS + ELIDED { Some((MAX_ROWS / 2, MAX_ROWS / 2)) } else { None }
}
fn pad(text: &str, size: usize, right: bool) -> String {
let missing = size.saturating_sub(width(text));
if right {
format!("{}{}", " ".repeat(missing), text)
} else {
format!("{}{}", text, " ".repeat(missing))
}
}
fn centre(text: &str, size: usize) -> String {
let missing = size.saturating_sub(width(text));
let left = missing / 2;
format!("{}{}{}", " ".repeat(left), text, " ".repeat(missing - left))
}
fn widths(result: &QueryResult, cells: &[Vec<String>], types: bool) -> Vec<usize> {
(0..result.width())
.map(|column| {
let mut size = width(&result.names()[column]);
if types {
size = size.max(width(&type_name(&result.types()[column])));
}
for row in cells {
size = size.max(width(&row[column]));
}
size
})
.collect()
}
struct Glyphs {
top: [&'static str; 4],
middle: [&'static str; 4],
bottom: [&'static str; 4],
vertical: &'static str,
}
const BOX_GLYPHS: Glyphs = Glyphs {
top: ["┌", "─", "┬", "┐"],
middle: ["├", "─", "┼", "┤"],
bottom: ["└", "─", "┴", "┘"],
vertical: "│",
};
const TABLE_GLYPHS: Glyphs = Glyphs {
top: ["+", "-", "+", "+"],
middle: ["+", "-", "+", "+"],
bottom: ["+", "-", "+", "+"],
vertical: "|",
};
fn rule(widths: &[usize], glyphs: &[&str; 4]) -> String {
let parts: Vec<String> = widths.iter().map(|size| glyphs[1].repeat(size + 2)).collect();
format!("{}{}{}", glyphs[0], parts.join(glyphs[2]), glyphs[3])
}
fn row(parts: &[String], vertical: &str) -> String {
let mut out = String::from(vertical);
for part in parts {
let _ = write!(out, " {part} {vertical}");
}
out
}
fn duckbox(result: &QueryResult, cells: &[Vec<String>]) -> String {
let mut sizes = widths(result, cells, true);
let counts = Counts::of(result);
if let Some(needed) = counts.minimum_width() {
let total = box_width(&sizes);
if let Some(last) = sizes.last_mut() {
*last += needed.saturating_sub(total);
}
}
let right: Vec<bool> = result.types().iter().map(LogicalType::is_numeric).collect();
let mut out = String::new();
let _ = writeln!(out, "{}", rule(&sizes, &BOX_GLYPHS.top));
let heads: Vec<String> =
result.names().iter().zip(&sizes).map(|(name, size)| centre(name, *size)).collect();
let _ = writeln!(out, "{}", row(&heads, BOX_GLYPHS.vertical));
let types: Vec<String> =
result.types().iter().zip(&sizes).map(|(ty, size)| centre(&type_name(ty), *size)).collect();
let _ = writeln!(out, "{}", row(&types, BOX_GLYPHS.vertical));
if !cells.is_empty() {
let _ = writeln!(out, "{}", rule(&sizes, &BOX_GLYPHS.middle));
write_rows(&mut out, cells, &sizes, &right, BOX_GLYPHS.vertical);
}
let _ = writeln!(out, "{}", rule(&sizes, &BOX_GLYPHS.bottom));
for text in counts.footer(box_width(&sizes)) {
let _ = writeln!(out, "{text}");
}
out
}
fn box_width(sizes: &[usize]) -> usize {
sizes.iter().map(|size| size + 2).sum::<usize>() + sizes.len() + 1
}
const HINT: &str = "use .last to show entire result";
struct Counts {
rows: usize,
columns: usize,
shown: Option<usize>,
}
impl Counts {
fn of(result: &QueryResult) -> Self {
let rows = result.len();
let shown = shown(rows).map(|(head, tail)| head + tail);
Self { rows, columns: result.width(), shown }
}
fn row_text(&self) -> String {
format!("{} rows", self.rows)
}
fn shown_text(&self) -> Option<String> {
self.shown.map(|shown| format!("({shown} shown)"))
}
fn column_text(&self) -> String {
format!("{} columns", self.columns)
}
fn minimum_width(&self) -> Option<usize> {
if self.rows != 0 && self.shown.is_none() {
return None;
}
let widest = match self.shown_text() {
Some(text) => width(&text).max(width(&self.row_text())),
None => width(&self.row_text()),
};
Some(widest + 4)
}
fn footer(&self, total: usize) -> Vec<String> {
if self.rows != 0 && self.rows < 10 {
return Vec::new();
}
let mut rows = self.row_text();
let columns = self.column_text();
let with_columns =
self.rows >= 10 && self.columns > 1 && total >= width(&rows) + width(&columns) + 6;
let mut separate = self.shown_text();
if let Some(shown) = &separate {
let taken = if with_columns { width(&columns) } else { 0 };
if total.saturating_sub(taken) >= width(&rows) + width(shown) + 5 {
rows = format!("{rows} {shown}");
separate = None;
}
}
if with_columns {
let mut lines = vec![spread(&rows, &columns, total, self.shown.is_some())];
if let Some(shown) = separate {
lines.push(pad(&format!(" {shown}"), total - 1, false));
}
return lines;
}
if total < width(&rows) + 4 {
return Vec::new();
}
let mut lines = vec![middle(&rows, total, total - 2)];
if let Some(shown) = separate {
lines.push(middle(&shown, total, total - 1));
}
lines
}
}
fn spread(left: &str, right: &str, total: usize, hint: bool) -> String {
let line = total - 2;
let gap = line.saturating_sub(2 + width(left) + width(right));
if hint && gap >= width(HINT) + 10 {
let spare = gap - width(HINT);
let before = spare / 2;
return format!(
" {left}{}{HINT}{}{right}",
" ".repeat(before),
" ".repeat(spare - before)
);
}
format!(" {left}{}{right}", " ".repeat(gap))
}
fn middle(text: &str, total: usize, line: usize) -> String {
let left = total.saturating_sub(width(text)) / 2;
let right = line.saturating_sub(left + width(text));
format!("{}{text}{}", " ".repeat(left), " ".repeat(right))
}
fn write_rows(
out: &mut String,
cells: &[Vec<String>],
sizes: &[usize],
right: &[bool],
vertical: &str,
) {
let dots = shown(cells.len());
for (at, values) in cells.iter().enumerate() {
if let Some((head, tail)) = dots {
if at == head {
let parts = dot_row(cells, sizes, right, head, tail);
for _ in 0..ELIDED {
let _ = writeln!(out, "{}", row(&parts, vertical));
}
}
if at >= head && at < cells.len() - tail {
continue;
}
}
let parts: Vec<String> = values
.iter()
.zip(sizes)
.zip(right)
.map(|((value, size), right)| pad(value, *size, *right))
.collect();
let _ = writeln!(out, "{}", row(&parts, vertical));
}
}
fn dot_row(
cells: &[Vec<String>],
sizes: &[usize],
right: &[bool],
head: usize,
tail: usize,
) -> Vec<String> {
let above = cells.get(head.wrapping_sub(1));
let below = cells.get(cells.len() - tail);
sizes
.iter()
.zip(right)
.enumerate()
.map(|(column, (size, right))| {
let edge = |row: Option<&Vec<String>>| {
row.and_then(|values| values.get(column)).map_or(usize::MAX, |value| width(value))
};
let shortest = edge(above).min(edge(below));
let inset = (shortest.saturating_sub(1) / 2).min(size.saturating_sub(1));
let before = if *right { size.saturating_sub(1 + inset) } else { inset };
pad(&format!("{}·", " ".repeat(before)), *size, false)
})
.collect()
}
fn boxed(result: &QueryResult, cells: &[Vec<String>], glyphs: Glyphs) -> String {
let sizes = widths(result, cells, false);
let right = vec![false; result.width()];
let mut out = String::new();
let _ = writeln!(out, "{}", rule(&sizes, &glyphs.top));
let heads: Vec<String> =
result.names().iter().zip(&sizes).map(|(name, size)| centre(name, *size)).collect();
let _ = writeln!(out, "{}", row(&heads, glyphs.vertical));
let _ = writeln!(out, "{}", rule(&sizes, &glyphs.middle));
write_rows(&mut out, cells, &sizes, &right, glyphs.vertical);
let _ = writeln!(out, "{}", rule(&sizes, &glyphs.bottom));
out
}
fn markdown(result: &QueryResult, cells: &[Vec<String>]) -> String {
let sizes = widths(result, cells, false);
let numeric: Vec<bool> = result.types().iter().map(LogicalType::is_numeric).collect();
let right = vec![false; result.width()];
let mut out = String::new();
let heads: Vec<String> =
result.names().iter().zip(&sizes).map(|(name, size)| centre(name, *size)).collect();
let _ = writeln!(out, "{}", row(&heads, "|"));
let rules: Vec<String> = sizes
.iter()
.zip(&numeric)
.map(
|(size, right)| {
if *right { format!("{}:", "-".repeat(size + 1)) } else { "-".repeat(size + 2) }
},
)
.collect();
let _ = writeln!(out, "|{}|", rules.join("|"));
let mut rows = String::new();
write_rows(&mut rows, cells, &sizes, &right, "|");
out.push_str(&rows);
out
}
fn line(result: &QueryResult, cells: &[Vec<String>]) -> String {
let widest =
result.names().iter().map(|name| width(name)).max().unwrap_or(0).max(LINE_NAME_WIDTH);
let mut out = String::new();
for (at, values) in cells.iter().enumerate() {
if at > 0 {
out.push('\n');
}
for (name, value) in result.names().iter().zip(values) {
let _ = writeln!(out, "{} = {}", pad(name, widest, true), value);
}
}
out
}
fn separated(result: &QueryResult, cells: &[Vec<String>], settings: &Settings) -> String {
let quoted = settings.format == Format::Csv;
let mut out = String::new();
if settings.header {
let heads: Vec<String> = result
.names()
.iter()
.map(|name| if quoted { csv(name, settings) } else { name.clone() })
.collect();
out.push_str(&heads.join(&settings.separator));
out.push_str(&settings.newline);
}
for values in cells {
let parts: Vec<String> = values
.iter()
.map(|value| if quoted { csv(value, settings) } else { value.clone() })
.collect();
out.push_str(&parts.join(&settings.separator));
out.push_str(&settings.newline);
}
out
}
fn csv(text: &str, settings: &Settings) -> String {
let awkward =
text.contains(&settings.separator) || text.bytes().any(|byte| AWKWARD[byte as usize]);
if awkward { format!("\"{}\"", text.replace('"', "\"\"")) } else { text.to_string() }
}
static AWKWARD: [bool; 256] = {
let mut table = [false; 256];
let mut byte = 0;
while byte < 256 {
table[byte] = byte < 0x20 || byte == 0x7f || byte >= 0x80;
byte += 1;
}
table[b'"' as usize] = true;
table[b'\'' as usize] = true;
table
};
fn json(result: &QueryResult, settings: &Settings, array: bool) -> String {
let mut out = String::new();
for row in 0..result.len() {
let parts: Vec<String> = (0..result.width())
.map(|column| {
format!(
"{}:{}",
json_string(&result.names()[column]),
json_value(&result.value_at(row, column))
)
})
.collect();
let object = format!("{{{}}}", parts.join(","));
if array {
if row == 0 {
out.push('[');
}
out.push_str(&object);
if row + 1 < result.len() {
out.push_str(",\n");
} else {
out.push_str("]\n");
}
} else {
let _ = writeln!(out, "{object}");
}
}
if array && result.is_empty() {
out.push_str("[]\n");
}
let _ = settings;
out
}
fn json_string(text: &str) -> String {
let mut out = String::with_capacity(text.len() + 2);
out.push('"');
for character in text.chars() {
match character {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
other if (other as u32) < 0x20 => {
let _ = write!(out, "\\u{:04x}", other as u32);
}
other => out.push(other),
}
}
out.push('"');
out
}
fn json_value(value: &Value) -> String {
match value {
Value::Null => "null".to_string(),
Value::Boolean(flag) => flag.to_string(),
Value::List { values, .. } => {
let parts: Vec<String> = values.iter().map(json_value).collect();
format!("[{}]", parts.join(","))
}
Value::Struct(fields) => {
let parts: Vec<String> = fields
.iter()
.map(|(name, value)| format!("{}:{}", json_string(name), json_value(value)))
.collect();
format!("{{{}}}", parts.join(","))
}
other if is_number(other) => other.to_string(),
other => json_string(&other.to_string()),
}
}
fn is_number(value: &Value) -> bool {
matches!(
value,
Value::TinyInt(_)
| Value::SmallInt(_)
| Value::Integer(_)
| Value::BigInt(_)
| Value::HugeInt(_)
| Value::UTinyInt(_)
| Value::USmallInt(_)
| Value::UInteger(_)
| Value::UBigInt(_)
| Value::UHugeInt(_)
| Value::Float(_)
| Value::Double(_)
| Value::Decimal { .. }
)
}
fn quote(result: &QueryResult, settings: &Settings) -> String {
let mut out = String::new();
if settings.header {
let heads: Vec<String> =
result.names().iter().map(|name| format!("'{}'", name.replace('\'', "''"))).collect();
out.push_str(&heads.join(&settings.separator));
out.push_str(&settings.newline);
}
for row in 0..result.len() {
let parts: Vec<String> =
(0..result.width()).map(|column| sql_literal(&result.value_at(row, column))).collect();
out.push_str(&parts.join(&settings.separator));
out.push_str(&settings.newline);
}
out
}
fn insert(result: &QueryResult, settings: &Settings) -> String {
let mut out = String::new();
let columns = result.names().join(",");
for row in 0..result.len() {
let parts: Vec<String> =
(0..result.width()).map(|column| sql_literal(&result.value_at(row, column))).collect();
let _ = writeln!(
out,
"INSERT INTO \"{}\"({}) VALUES({});",
settings.table,
columns,
parts.join(",")
);
}
out
}
fn sql_literal(value: &Value) -> String {
match value {
Value::Null => "NULL".to_string(),
other if is_number(other) => other.to_string(),
Value::Boolean(flag) => flag.to_string(),
other => format!("'{}'", other.to_string().replace('\'', "''")),
}
}
fn html(result: &QueryResult, cells: &[Vec<String>], settings: &Settings) -> String {
let mut out = String::new();
if settings.header {
out.push_str("<tr>");
for name in result.names() {
let _ = writeln!(out, "<th>{}</th>", escape(name));
}
out.push_str("</tr>\n");
}
for values in cells {
out.push_str("<tr>");
for value in values {
let _ = writeln!(out, "<td>{}</td>", escape(value));
}
out.push_str("</tr>\n");
}
out
}
fn escape(text: &str) -> String {
text.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
}
fn ascii(result: &QueryResult, cells: &[Vec<String>], settings: &Settings) -> String {
let mut out = String::new();
if settings.header {
for name in result.names() {
let _ = writeln!(out, "{name}");
}
}
for values in cells {
for value in values {
let _ = writeln!(out, "{value}");
}
}
let _ = settings;
out
}
fn column(result: &QueryResult, cells: &[Vec<String>]) -> String {
let sizes = widths(result, cells, false);
let mut out = String::new();
let heads: Vec<String> =
result.names().iter().zip(&sizes).map(|(name, size)| pad(name, *size, false)).collect();
let _ = writeln!(out, "{}", heads.join(" "));
let rules: Vec<String> = sizes.iter().map(|size| "-".repeat(*size)).collect();
let _ = writeln!(out, "{}", rules.join(" "));
for values in cells {
let parts: Vec<String> =
values.iter().zip(&sizes).map(|(value, size)| pad(value, *size, false)).collect();
let _ = writeln!(out, "{}", parts.join(" "));
}
out
}