1use inillucent_value::Value;
15
16#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
18pub enum Mode {
19 #[default]
21 List,
22 Column,
24 Line,
26 Csv,
28 Tabs,
30 Quote,
32 Insert,
34 Json,
36 Markdown,
38 Table,
40 Box,
42 Html,
44}
45
46impl Mode {
47 pub fn from_name(name: &str) -> Option<Mode> {
49 match name.to_ascii_lowercase().as_str() {
50 "list" => Some(Mode::List),
51 "column" | "columns" => Some(Mode::Column),
52 "line" | "lines" => Some(Mode::Line),
53 "csv" => Some(Mode::Csv),
54 "tabs" => Some(Mode::Tabs),
55 "quote" => Some(Mode::Quote),
56 "insert" => Some(Mode::Insert),
57 "json" => Some(Mode::Json),
58 "markdown" => Some(Mode::Markdown),
59 "table" => Some(Mode::Table),
60 "box" => Some(Mode::Box),
61 "html" => Some(Mode::Html),
62 _ => None,
63 }
64 }
65
66 pub fn separator(self) -> &'static str {
71 match self {
72 Mode::Csv | Mode::Quote => ",",
73 Mode::Tabs => "\t",
74 _ => "|",
75 }
76 }
77
78 pub fn name(self) -> &'static str {
80 match self {
81 Mode::List => "list",
82 Mode::Column => "column",
83 Mode::Line => "line",
84 Mode::Csv => "csv",
85 Mode::Tabs => "tabs",
86 Mode::Quote => "quote",
87 Mode::Insert => "insert",
88 Mode::Json => "json",
89 Mode::Markdown => "markdown",
90 Mode::Table => "table",
91 Mode::Box => "box",
92 Mode::Html => "html",
93 }
94 }
95}
96
97#[derive(Clone, Debug)]
99pub struct Layout {
100 pub mode: Mode,
102 pub separator: String,
104 pub row_separator: String,
106 pub null: String,
108 pub headers: bool,
110 pub table: String,
112 pub widths: Vec<usize>,
114 pub to_stdout: bool,
125}
126
127impl Default for Layout {
128 fn default() -> Layout {
130 Layout {
131 mode: Mode::List,
132 separator: "|".to_string(),
133 row_separator: "\n".to_string(),
134 null: String::new(),
135 headers: false,
136 table: "tab".to_string(),
139 widths: Vec::new(),
140 to_stdout: true,
141 }
142 }
143}
144
145pub fn render(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
155 match layout.mode {
156 Mode::List | Mode::Tabs => separated(layout, columns, rows),
157 Mode::Csv => csv(layout, columns, rows),
158 Mode::Quote => quoted(layout, columns, rows),
159 Mode::Line => lines(layout, columns, rows),
160 Mode::Insert => inserts(layout, columns, rows),
161 Mode::Json => json(layout, columns, rows),
162 Mode::Column => aligned(layout, columns, rows),
163 Mode::Markdown | Mode::Table | Mode::Box => drawn(layout, columns, rows),
164 Mode::Html => html(layout, columns, rows),
165 }
166}
167
168fn plain(layout: &Layout, value: &Value<'static>) -> String {
170 match value {
171 Value::Null => layout.null.clone(),
172 Value::Integer(number) => number.to_string(),
173 Value::Real(_) => number_text(value),
174 Value::Text(text) => printable(text.raw()),
175 Value::Blob(blob) => printable(blob.raw()),
176 }
177}
178
179fn printable(bytes: &[u8]) -> String {
187 let end = bytes
188 .iter()
189 .position(|byte| *byte == 0)
190 .unwrap_or(bytes.len());
191 let visible = bytes.get(..end).unwrap_or(bytes);
192 let mut out = String::with_capacity(visible.len());
193 for chunk in String::from_utf8_lossy(visible).chars() {
194 let code = chunk as u32;
195 if chunk == '\n' {
196 out.push(chunk);
197 } else if code < 0x20 {
198 out.push('^');
199 out.push(char::from_u32(code + 0x40).unwrap_or('?'));
200 } else if code == 0x7f {
201 out.push_str("^?");
202 } else {
203 out.push(chunk);
204 }
205 }
206 out
207}
208
209fn number_text(value: &Value<'static>) -> String {
211 let cast = inillucent_value::cast::cast_value(
212 value.clone(),
213 inillucent_value::Affinity::Text,
214 inillucent_value::TextEncoding::Utf8,
215 );
216 match cast {
217 Ok(Value::Text(text)) => String::from_utf8_lossy(text.raw()).into_owned(),
218 _ => String::new(),
219 }
220}
221
222pub fn literal(value: &Value<'static>) -> String {
224 match value {
225 Value::Null => "NULL".to_string(),
226 Value::Integer(number) => number.to_string(),
227 Value::Real(_) => number_text(value),
228 Value::Text(text) => {
229 let body = String::from_utf8_lossy(text.raw()).replace('\'', "''");
230 format!("'{body}'")
231 }
232 Value::Blob(blob) => {
233 let mut out = String::from("x'");
236 for byte in blob.raw() {
237 out.push_str(&format!("{byte:02x}"));
238 }
239 out.push('\'');
240 out
241 }
242 }
243}
244
245pub fn quote_literal(value: &Value<'static>) -> String {
262 let Value::Text(text) = value else {
263 return literal(value);
264 };
265 let bytes = text.raw();
266 let end = bytes
267 .iter()
268 .position(|byte| *byte == 0)
269 .unwrap_or(bytes.len());
270 let text = String::from_utf8_lossy(bytes.get(..end).unwrap_or(bytes));
271 if !text.chars().any(|character| u32::from(character) < 0x20) {
272 return format!("'{}'", text.replace('\'', "''"));
273 }
274 let mut out = String::from("unistr('");
275 for character in text.chars() {
276 match character {
277 '\'' => out.push_str("''"),
278 '\\' => out.push_str("\\\\"),
279 control if u32::from(control) < 0x20 => {
280 out.push_str(&format!("\\u{:04x}", u32::from(control)));
281 }
282 other => out.push(other),
283 }
284 }
285 out.push_str("')");
286 out
287}
288
289fn quote_name(name: &str) -> String {
293 quote_literal(&Value::owned_text(name.as_bytes()).unwrap_or(Value::Null))
294}
295
296fn separated(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
298 let separator = if layout.mode == Mode::Tabs {
299 "\t"
300 } else {
301 layout.separator.as_str()
302 };
303 let mut out = Vec::with_capacity(rows.len() + 1);
304 if layout.headers {
305 out.push(columns.join(separator));
306 }
307 for row in rows {
308 let cells: Vec<String> = row.iter().map(|value| plain(layout, value)).collect();
309 out.push(cells.join(separator));
310 }
311 out
312}
313
314fn csv(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
316 let mut out = Vec::with_capacity(rows.len() + 1);
317 if layout.headers {
318 out.push(
319 columns
320 .iter()
321 .map(|name| csv_field(name))
322 .collect::<Vec<String>>()
323 .join(","),
324 );
325 }
326 for row in rows {
327 let cells: Vec<String> = row.iter().map(|value| csv_cell(layout, value)).collect();
328 out.push(cells.join(","));
329 }
330 if layout.row_separator.ends_with(CRLF) {
353 for line in &mut out {
354 line.push(CR);
355 if cfg!(windows) && layout.to_stdout {
356 line.push(CR);
357 }
358 }
359 }
360 out
361}
362
363const CRLF: &str = "\r\n";
365
366const CR: char = '\r';
368
369fn csv_cell(layout: &Layout, value: &Value<'static>) -> String {
374 let text = plain(layout, value);
375 if text.is_empty() && !value.is_null() {
383 return "\"\"".to_string();
384 }
385 csv_field(&text)
386}
387
388fn csv_field(text: &str) -> String {
397 let needs = text.contains(',')
398 || text.contains('"')
399 || text.contains('\n')
400 || text.contains('\r')
401 || text.contains('^');
402 if !needs {
403 return text.to_string();
404 }
405 format!("\"{}\"", text.replace('"', "\"\""))
406}
407
408fn quoted(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
410 let mut out = Vec::with_capacity(rows.len() + 1);
411 if layout.headers {
412 out.push(
413 columns
414 .iter()
415 .map(|name| quote_name(name))
416 .collect::<Vec<String>>()
417 .join(&layout.separator),
418 );
419 }
420 for row in rows {
421 let cells: Vec<String> = row.iter().map(quote_literal).collect();
422 out.push(cells.join(&layout.separator));
423 }
424 out
425}
426
427fn lines(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
429 let width = columns
430 .iter()
431 .map(|name| name.chars().count())
432 .max()
433 .unwrap_or(0);
434 let mut out = Vec::new();
435 for (index, row) in rows.iter().enumerate() {
436 if index > 0 {
437 out.push(String::new());
438 }
439 for (position, value) in row.iter().enumerate() {
440 let name = columns.get(position).cloned().unwrap_or_default();
441 out.push(format!("{name:>width$}: {}", plain(layout, value)));
442 }
443 }
444 out
445}
446
447fn inserts(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
453 let target = if layout.headers {
454 let names: Vec<String> = columns.iter().map(|name| insert_column(name)).collect();
455 format!("{}({})", layout.table, names.join(","))
456 } else {
457 layout.table.clone()
458 };
459 rows.iter()
460 .map(|row| {
461 let cells: Vec<String> = row.iter().map(quote_literal).collect();
462 format!("INSERT INTO {target} VALUES({});", cells.join(","))
463 })
464 .collect()
465}
466
467fn insert_column(name: &str) -> String {
475 let plain = name
476 .chars()
477 .next()
478 .is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
479 && name
480 .chars()
481 .all(|character| character.is_ascii_alphanumeric() || character == '_')
482 && inillucent_driver::keyword_lookup(name.as_bytes()).is_none();
483 if plain {
484 return name.to_string();
485 }
486 format!("\"{}\"", name.replace('"', "\"\""))
487}
488
489fn json(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
491 let _ = layout;
492 if rows.is_empty() {
493 return Vec::new();
496 }
497 let mut out = Vec::with_capacity(rows.len());
498 for (index, row) in rows.iter().enumerate() {
499 let members: Vec<String> = row
500 .iter()
501 .enumerate()
502 .map(|(position, value)| {
503 let name = columns.get(position).cloned().unwrap_or_default();
504 format!(
505 "\"{}\":{}",
506 inillucent_base::json::escape(&name),
507 json_value(value)
508 )
509 })
510 .collect();
511 let open = if index == 0 { "[" } else { "" };
512 let close = if index + 1 == rows.len() { "]" } else { "," };
513 out.push(format!("{open}{{{}}}{close}", members.join(",")));
514 }
515 out
516}
517
518fn json_value(value: &Value<'static>) -> String {
520 match value {
521 Value::Null => "null".to_string(),
522 Value::Integer(number) => number.to_string(),
523 Value::Real(_) => number_text(value),
524 Value::Text(text) => format!(
525 "\"{}\"",
526 inillucent_base::json::escape(&String::from_utf8_lossy(text.raw()))
527 ),
528 Value::Blob(blob) => {
532 let escaped: String = blob
533 .raw()
534 .iter()
535 .map(|byte| format!("\\u{byte:04x}"))
536 .collect();
537 format!("\"{escaped}\"")
538 }
539 }
540}
541
542fn widths(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<usize> {
546 let mut widths: Vec<usize> = columns
547 .iter()
548 .map(|name| {
549 if layout.headers {
550 name.chars().count()
551 } else {
552 0
553 }
554 })
555 .collect();
556 for row in rows {
557 for (index, value) in row.iter().enumerate() {
558 let width = plain(layout, value).chars().count();
559 match widths.get_mut(index) {
560 Some(existing) => *existing = (*existing).max(width),
561 None => widths.push(width),
562 }
563 }
564 }
565 for (index, fixed) in layout.widths.iter().enumerate() {
566 if *fixed == 0 {
567 continue;
568 }
569 if let Some(existing) = widths.get_mut(index) {
570 *existing = *fixed;
571 }
572 }
573 widths
574}
575
576fn aligned(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
578 let widths = widths(layout, columns, rows);
579 let mut out = Vec::with_capacity(rows.len() + 2);
580 if layout.headers {
581 let centred: Vec<String> = columns
582 .iter()
583 .enumerate()
584 .map(|(index, name)| centre(name, widths.get(index).copied().unwrap_or(0)))
585 .collect();
586 out.push(centred.join(" ").trim_end().to_string());
587 out.push(
588 widths
589 .iter()
590 .map(|width| "-".repeat(*width))
591 .collect::<Vec<String>>()
592 .join(" "),
593 );
594 }
595 for row in rows {
596 let cells: Vec<String> = row
597 .iter()
598 .enumerate()
599 .map(|(index, value)| align(layout, value, widths.get(index).copied().unwrap_or(0)))
600 .collect();
601 out.push(pad_row(&cells, &widths));
602 }
603 out
604}
605
606fn align(layout: &Layout, value: &Value<'static>, width: usize) -> String {
611 let text = plain(layout, value);
612 if matches!(value, Value::Integer(_) | Value::Real(_)) {
613 return format!("{text:>width$}");
614 }
615 text
616}
617
618fn centre(text: &str, width: usize) -> String {
624 let length = text.chars().count();
625 if length >= width {
626 return text.to_string();
627 }
628 let left = (width - length) / 2;
629 let right = width - length - left;
630 format!("{}{text}{}", " ".repeat(left), " ".repeat(right))
631}
632
633fn pad_row(cells: &[String], widths: &[usize]) -> String {
635 let padded: Vec<String> = cells
636 .iter()
637 .enumerate()
638 .map(|(index, cell)| {
639 let width = widths.get(index).copied().unwrap_or(0);
640 format!("{cell:<width$}")
641 })
642 .collect();
643 padded.join(" ").trim_end().to_string()
644}
645
646struct Frame {
648 left: &'static str,
649 middle: &'static str,
650 right: &'static str,
651 horizontal: &'static str,
652 vertical: &'static str,
653}
654
655fn drawn(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
657 let widths = widths(layout, columns, rows);
658 let frame = match layout.mode {
659 Mode::Markdown => Frame {
660 left: "|",
661 middle: "|",
662 right: "|",
663 horizontal: "-",
664 vertical: "|",
665 },
666 Mode::Box => Frame {
671 left: "\u{256d}",
672 middle: "\u{252c}",
673 right: "\u{256e}",
674 horizontal: "\u{2500}",
675 vertical: "\u{2502}",
676 },
677 _ => Frame {
678 left: "+",
679 middle: "+",
680 right: "+",
681 horizontal: "-",
682 vertical: "|",
683 },
684 };
685 let mut out = Vec::with_capacity(rows.len() + 4);
686 let rule = rule_line(&frame, &widths);
687 if layout.mode != Mode::Markdown {
688 out.push(rule.clone());
689 }
690 if layout.headers {
691 drawn_header(layout, &frame, columns, &widths, &rule, &mut out);
692 }
693 for row in rows {
694 let cells: Vec<String> = row
695 .iter()
696 .enumerate()
697 .map(|(index, value)| align(layout, value, widths.get(index).copied().unwrap_or(0)))
698 .collect();
699 out.push(drawn_row(&frame, &cells, &widths, layout, false));
700 }
701 if layout.mode == Mode::Box {
702 out.push(rule_line(
703 &Frame {
704 left: "\u{2570}",
705 middle: "\u{2534}",
706 right: "\u{256f}",
707 ..frame
708 },
709 &widths,
710 ));
711 } else if layout.mode != Mode::Markdown {
712 out.push(rule);
713 }
714 out
715}
716
717fn drawn_header(
729 layout: &Layout,
730 frame: &Frame,
731 columns: &[String],
732 widths: &[usize],
733 rule: &str,
734 out: &mut Vec<String>,
735) {
736 let centred: Vec<String> = columns
737 .iter()
738 .enumerate()
739 .map(|(index, name)| centre(name, widths.get(index).copied().unwrap_or(0)))
740 .collect();
741 out.push(drawn_row(frame, ¢red, widths, layout, true));
742 out.push(match layout.mode {
743 Mode::Markdown => markdown_rule(widths),
744 Mode::Box => rule_line(
745 &Frame {
746 left: "\u{255e}",
747 middle: "\u{256a}",
748 right: "\u{2561}",
749 horizontal: "\u{2550}",
750 ..*frame
751 },
752 widths,
753 ),
754 _ => rule.to_string(),
755 });
756}
757
758fn rule_line(frame: &Frame, widths: &[usize]) -> String {
760 let parts: Vec<String> = widths
761 .iter()
762 .map(|width| frame.horizontal.repeat(width + 2))
763 .collect();
764 format!("{}{}{}", frame.left, parts.join(frame.middle), frame.right)
765}
766
767fn markdown_rule(widths: &[usize]) -> String {
769 let parts: Vec<String> = widths.iter().map(|width| "-".repeat(width + 2)).collect();
770 format!("|{}|", parts.join("|"))
771}
772
773fn drawn_row(
775 frame: &Frame,
776 cells: &[String],
777 widths: &[usize],
778 layout: &Layout,
779 header: bool,
780) -> String {
781 let _ = (layout, header);
782 let padded: Vec<String> = widths
783 .iter()
784 .enumerate()
785 .map(|(index, width)| {
786 let cell = cells.get(index).cloned().unwrap_or_default();
787 format!(" {cell:<width$} ")
788 })
789 .collect();
790 format!(
791 "{}{}{}",
792 frame.vertical,
793 padded.join(frame.vertical),
794 frame.vertical
795 )
796}
797
798fn html(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
804 let mut out = Vec::new();
805 if layout.headers {
806 out.push("<TR>".to_string());
807 for name in columns {
808 out.push(format!("<TH>{}", html_escape(name)));
809 }
810 out.push("</TR>".to_string());
811 }
812 for row in rows {
813 out.push("<TR>".to_string());
814 for value in row {
815 out.push(format!("<TD>{}", html_escape(&plain(layout, value))));
816 }
817 out.push("</TR>".to_string());
818 }
819 out
820}
821
822fn html_escape(text: &str) -> String {
824 text.replace('&', "&")
825 .replace('<', "<")
826 .replace('>', ">")
827 .replace('"', """)
828}
829
830#[cfg(test)]
831mod tests {
832 use super::*;
833
834 fn sample() -> (Vec<String>, Vec<Vec<Value<'static>>>) {
836 let columns = vec!["a".to_string(), "b".to_string()];
837 let rows = vec![vec![
838 Value::Integer(1),
839 Value::owned_text(b"two").expect("owns"),
840 ]];
841 (columns, rows)
842 }
843
844 #[test]
846 fn the_default_is_a_pipe_separated_line() {
847 let (columns, rows) = sample();
848 let out = render(&Layout::default(), &columns, &rows);
849 assert_eq!(out, vec!["1|two"]);
850 }
851
852 #[test]
854 fn headers_use_the_same_layout() {
855 let (columns, rows) = sample();
856 let layout = Layout {
857 headers: true,
858 ..Layout::default()
859 };
860 let out = render(&layout, &columns, &rows);
861 assert_eq!(out, vec!["a|b", "1|two"]);
862 }
863
864 #[test]
874 fn a_csv_record_ends_the_way_its_destination_expects() {
875 let (columns, rows) = sample();
876 let to_a_file = Layout {
877 mode: Mode::Csv,
878 separator: ",".to_string(),
879 row_separator: "\r\n".to_string(),
880 to_stdout: false,
881 ..Layout::default()
882 };
883 assert_eq!(render(&to_a_file, &columns, &rows), vec!["1,two\r"]);
884 let to_the_terminal = Layout {
885 to_stdout: true,
886 ..to_a_file
887 };
888 let expected = if cfg!(windows) {
889 "1,two\r\r"
890 } else {
891 "1,two\r"
892 };
893 assert_eq!(render(&to_the_terminal, &columns, &rows), vec![expected]);
894 }
895
896 #[test]
898 fn csv_quotes_only_what_it_must() {
899 assert_eq!(csv_field("plain"), "plain");
900 assert_eq!(csv_field("a,b"), "\"a,b\"");
901 assert_eq!(csv_field("say \"hi\""), "\"say \"\"hi\"\"\"");
902 }
903
904 #[test]
913 fn csv_tells_an_empty_value_from_a_null() {
914 let columns = vec!["x".to_string()];
915 let rows = vec![
916 vec![Value::Null],
917 vec![Value::owned_blob(&[0, 1, 2]).expect("owns")],
918 vec![Value::owned_text(b"").expect("owns")],
919 vec![Value::owned_text(b"kept").expect("owns")],
920 ];
921 let layout = Layout {
922 mode: Mode::Csv,
923 separator: ",".to_string(),
924 ..Layout::default()
925 };
926 assert_eq!(
927 render(&layout, &columns, &rows),
928 vec!["", "\"\"", "\"\"", "kept"]
929 );
930 }
931
932 #[test]
934 fn control_characters_are_escaped() {
935 assert_eq!(printable(b"ab"), "ab");
936 assert_eq!(printable(&[0x01, 0x02]), "^A^B");
937 assert_eq!(printable(&[0x09, b't']), "^It");
938 assert_eq!(printable(&[0x7f]), "^?");
939 assert_eq!(printable(b"a\nb"), "a\nb");
940 assert_eq!(printable(&[b'a', 0, b'b']), "a");
941 }
942
943 #[test]
945 fn quote_mode_writes_literals() {
946 let columns = vec!["x".to_string()];
947 let rows = vec![
948 vec![Value::Null],
949 vec![Value::owned_blob(&[1, 255]).expect("owns")],
950 vec![Value::owned_text(b"it's").expect("owns")],
951 ];
952 let layout = Layout {
953 mode: Mode::Quote,
954 ..Layout::default()
955 };
956 let out = render(&layout, &columns, &rows);
957 assert_eq!(out, vec!["NULL", "x'01ff'", "'it''s'"]);
958 }
959
960 #[test]
962 fn every_mode_name_round_trips() {
963 for mode in [
964 Mode::List,
965 Mode::Column,
966 Mode::Line,
967 Mode::Csv,
968 Mode::Tabs,
969 Mode::Quote,
970 Mode::Insert,
971 Mode::Json,
972 Mode::Markdown,
973 Mode::Table,
974 Mode::Box,
975 Mode::Html,
976 ] {
977 assert_eq!(Mode::from_name(mode.name()), Some(mode), "{}", mode.name());
978 }
979 assert_eq!(Mode::from_name("nonsense"), None);
980 }
981
982 #[test]
984 fn a_table_is_drawn_with_rules() {
985 let (columns, rows) = sample();
986 let layout = Layout {
987 mode: Mode::Table,
988 headers: true,
989 ..Layout::default()
990 };
991 let out = render(&layout, &columns, &rows);
992 assert_eq!(out.len(), 5, "{out:#?}");
993 assert!(out.first().is_some_and(|line| line.starts_with('+')));
994 assert!(out.last().is_some_and(|line| line.starts_with('+')));
995 }
996
997 #[test]
999 fn the_shell_escapes_through_the_base_crate() {
1000 assert_eq!(inillucent_base::json::escape("a\"b"), "a\\\"b");
1001 assert_eq!(inillucent_base::json::escape("a\nb"), "a\\nb");
1002 assert_eq!(inillucent_base::json::escape("a\u{1}b"), "a\\u0001b");
1003 assert_eq!(inillucent_base::json::escape("a\u{8}b"), "a\\bb");
1006 assert_eq!(inillucent_base::json::escape("a\u{c}b"), "a\\fb");
1007 assert_eq!(inillucent_base::json::escape("a\u{7f}b"), "a\\u007fb");
1008 }
1009}