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> {
151 match layout.mode {
152 Mode::List | Mode::Tabs => separated(layout, columns, rows),
153 Mode::Csv => csv(layout, columns, rows),
154 Mode::Quote => quoted(layout, columns, rows),
155 Mode::Line => lines(layout, columns, rows),
156 Mode::Insert => inserts(layout, columns, rows),
157 Mode::Json => json(layout, columns, rows),
158 Mode::Column => aligned(layout, columns, rows),
159 Mode::Markdown | Mode::Table | Mode::Box => drawn(layout, columns, rows),
160 Mode::Html => html(layout, columns, rows),
161 }
162}
163
164fn plain(layout: &Layout, value: &Value<'static>) -> String {
166 match value {
167 Value::Null => layout.null.clone(),
168 Value::Integer(number) => number.to_string(),
169 Value::Real(_) => number_text(value),
170 Value::Text(text) => printable(text.raw()),
171 Value::Blob(blob) => printable(blob.raw()),
172 }
173}
174
175fn printable(bytes: &[u8]) -> String {
183 let end = bytes
184 .iter()
185 .position(|byte| *byte == 0)
186 .unwrap_or(bytes.len());
187 let visible = bytes.get(..end).unwrap_or(bytes);
188 let mut out = String::with_capacity(visible.len());
189 for chunk in String::from_utf8_lossy(visible).chars() {
190 let code = chunk as u32;
191 if chunk == '\n' {
192 out.push(chunk);
193 } else if code < 0x20 {
194 out.push('^');
195 out.push(char::from_u32(code + 0x40).unwrap_or('?'));
196 } else if code == 0x7f {
197 out.push_str("^?");
198 } else {
199 out.push(chunk);
200 }
201 }
202 out
203}
204
205fn number_text(value: &Value<'static>) -> String {
207 let cast = inillucent_value::cast::cast_value(
208 value.clone(),
209 inillucent_value::Affinity::Text,
210 inillucent_value::TextEncoding::Utf8,
211 );
212 match cast {
213 Ok(Value::Text(text)) => String::from_utf8_lossy(text.raw()).into_owned(),
214 _ => String::new(),
215 }
216}
217
218pub fn literal(value: &Value<'static>) -> String {
220 match value {
221 Value::Null => "NULL".to_string(),
222 Value::Integer(number) => number.to_string(),
223 Value::Real(_) => number_text(value),
224 Value::Text(text) => {
225 let body = String::from_utf8_lossy(text.raw()).replace('\'', "''");
226 format!("'{body}'")
227 }
228 Value::Blob(blob) => {
229 let mut out = String::from("x'");
232 for byte in blob.raw() {
233 out.push_str(&format!("{byte:02x}"));
234 }
235 out.push('\'');
236 out
237 }
238 }
239}
240
241fn separated(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
243 let separator = if layout.mode == Mode::Tabs {
244 "\t"
245 } else {
246 layout.separator.as_str()
247 };
248 let mut out = Vec::with_capacity(rows.len() + 1);
249 if layout.headers {
250 out.push(columns.join(separator));
251 }
252 for row in rows {
253 let cells: Vec<String> = row.iter().map(|value| plain(layout, value)).collect();
254 out.push(cells.join(separator));
255 }
256 out
257}
258
259fn csv(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
261 let mut out = Vec::with_capacity(rows.len() + 1);
262 if layout.headers {
263 out.push(
264 columns
265 .iter()
266 .map(|name| csv_field(name))
267 .collect::<Vec<String>>()
268 .join(","),
269 );
270 }
271 for row in rows {
272 let cells: Vec<String> = row.iter().map(|value| csv_cell(layout, value)).collect();
273 out.push(cells.join(","));
274 }
275 if layout.row_separator.ends_with(CRLF) {
298 for line in &mut out {
299 line.push(CR);
300 if cfg!(windows) && layout.to_stdout {
301 line.push(CR);
302 }
303 }
304 }
305 out
306}
307
308const CRLF: &str = "\r\n";
310
311const CR: char = '\r';
313
314fn csv_cell(layout: &Layout, value: &Value<'static>) -> String {
319 let text = plain(layout, value);
320 if text.is_empty() && !value.is_null() {
328 return "\"\"".to_string();
329 }
330 csv_field(&text)
331}
332
333fn csv_field(text: &str) -> String {
342 let needs = text.contains(',')
343 || text.contains('"')
344 || text.contains('\n')
345 || text.contains('\r')
346 || text.contains('^');
347 if !needs {
348 return text.to_string();
349 }
350 format!("\"{}\"", text.replace('"', "\"\""))
351}
352
353fn quoted(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
355 let mut out = Vec::with_capacity(rows.len() + 1);
356 if layout.headers {
357 out.push(
358 columns
359 .iter()
360 .map(|name| format!("'{}'", name.replace('\'', "''")))
361 .collect::<Vec<String>>()
362 .join(&layout.separator),
363 );
364 }
365 for row in rows {
366 let cells: Vec<String> = row.iter().map(literal).collect();
367 out.push(cells.join(&layout.separator));
368 }
369 out
370}
371
372fn lines(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
374 let width = columns
375 .iter()
376 .map(|name| name.chars().count())
377 .max()
378 .unwrap_or(0);
379 let mut out = Vec::new();
380 for (index, row) in rows.iter().enumerate() {
381 if index > 0 {
382 out.push(String::new());
383 }
384 for (position, value) in row.iter().enumerate() {
385 let name = columns.get(position).cloned().unwrap_or_default();
386 out.push(format!("{name:>width$}: {}", plain(layout, value)));
387 }
388 }
389 out
390}
391
392fn inserts(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
394 let _ = columns;
395 rows.iter()
396 .map(|row| {
397 let cells: Vec<String> = row.iter().map(literal).collect();
398 format!("INSERT INTO {} VALUES({});", layout.table, cells.join(","))
399 })
400 .collect()
401}
402
403fn json(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
405 let _ = layout;
406 if rows.is_empty() {
407 return Vec::new();
410 }
411 let mut out = Vec::with_capacity(rows.len());
412 for (index, row) in rows.iter().enumerate() {
413 let members: Vec<String> = row
414 .iter()
415 .enumerate()
416 .map(|(position, value)| {
417 let name = columns.get(position).cloned().unwrap_or_default();
418 format!(
419 "\"{}\":{}",
420 inillucent_base::json::escape(&name),
421 json_value(value)
422 )
423 })
424 .collect();
425 let open = if index == 0 { "[" } else { "" };
426 let close = if index + 1 == rows.len() { "]" } else { "," };
427 out.push(format!("{open}{{{}}}{close}", members.join(",")));
428 }
429 out
430}
431
432fn json_value(value: &Value<'static>) -> String {
434 match value {
435 Value::Null => "null".to_string(),
436 Value::Integer(number) => number.to_string(),
437 Value::Real(_) => number_text(value),
438 Value::Text(text) => format!(
439 "\"{}\"",
440 inillucent_base::json::escape(&String::from_utf8_lossy(text.raw()))
441 ),
442 Value::Blob(blob) => {
446 let escaped: String = blob
447 .raw()
448 .iter()
449 .map(|byte| format!("\\u{byte:04x}"))
450 .collect();
451 format!("\"{escaped}\"")
452 }
453 }
454}
455
456fn widths(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<usize> {
458 let mut widths: Vec<usize> = columns.iter().map(|name| name.chars().count()).collect();
459 for row in rows {
460 for (index, value) in row.iter().enumerate() {
461 let width = plain(layout, value).chars().count();
462 match widths.get_mut(index) {
463 Some(existing) => *existing = (*existing).max(width),
464 None => widths.push(width),
465 }
466 }
467 }
468 for (index, fixed) in layout.widths.iter().enumerate() {
469 if *fixed == 0 {
470 continue;
471 }
472 if let Some(existing) = widths.get_mut(index) {
473 *existing = *fixed;
474 }
475 }
476 widths
477}
478
479fn aligned(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
481 let widths = widths(layout, columns, rows);
482 let mut out = Vec::with_capacity(rows.len() + 2);
483 if layout.headers {
484 let centred: Vec<String> = columns
485 .iter()
486 .enumerate()
487 .map(|(index, name)| centre(name, widths.get(index).copied().unwrap_or(0)))
488 .collect();
489 out.push(centred.join(" ").trim_end().to_string());
490 out.push(
491 widths
492 .iter()
493 .map(|width| "-".repeat(*width))
494 .collect::<Vec<String>>()
495 .join(" "),
496 );
497 }
498 for row in rows {
499 let cells: Vec<String> = row
500 .iter()
501 .enumerate()
502 .map(|(index, value)| align(layout, value, widths.get(index).copied().unwrap_or(0)))
503 .collect();
504 out.push(pad_row(&cells, &widths));
505 }
506 out
507}
508
509fn align(layout: &Layout, value: &Value<'static>, width: usize) -> String {
514 let text = plain(layout, value);
515 if matches!(value, Value::Integer(_) | Value::Real(_)) {
516 return format!("{text:>width$}");
517 }
518 text
519}
520
521fn centre(text: &str, width: usize) -> String {
527 let length = text.chars().count();
528 if length >= width {
529 return text.to_string();
530 }
531 let left = (width - length) / 2;
532 let right = width - length - left;
533 format!("{}{text}{}", " ".repeat(left), " ".repeat(right))
534}
535
536fn pad_row(cells: &[String], widths: &[usize]) -> String {
538 let padded: Vec<String> = cells
539 .iter()
540 .enumerate()
541 .map(|(index, cell)| {
542 let width = widths.get(index).copied().unwrap_or(0);
543 format!("{cell:<width$}")
544 })
545 .collect();
546 padded.join(" ").trim_end().to_string()
547}
548
549struct Frame {
551 left: &'static str,
552 middle: &'static str,
553 right: &'static str,
554 horizontal: &'static str,
555 vertical: &'static str,
556}
557
558fn drawn(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
560 let widths = widths(layout, columns, rows);
561 let frame = match layout.mode {
562 Mode::Markdown => Frame {
563 left: "|",
564 middle: "|",
565 right: "|",
566 horizontal: "-",
567 vertical: "|",
568 },
569 Mode::Box => Frame {
574 left: "\u{256d}",
575 middle: "\u{252c}",
576 right: "\u{256e}",
577 horizontal: "\u{2500}",
578 vertical: "\u{2502}",
579 },
580 _ => Frame {
581 left: "+",
582 middle: "+",
583 right: "+",
584 horizontal: "-",
585 vertical: "|",
586 },
587 };
588 let mut out = Vec::with_capacity(rows.len() + 4);
589 let rule = rule_line(&frame, &widths);
590 if layout.mode != Mode::Markdown {
591 out.push(rule.clone());
592 }
593 let centred: Vec<String> = columns
594 .iter()
595 .enumerate()
596 .map(|(index, name)| centre(name, widths.get(index).copied().unwrap_or(0)))
597 .collect();
598 out.push(drawn_row(&frame, ¢red, &widths, layout, true));
599 out.push(match layout.mode {
600 Mode::Markdown => markdown_rule(&widths),
601 Mode::Box => rule_line(
602 &Frame {
603 left: "\u{255e}",
604 middle: "\u{256a}",
605 right: "\u{2561}",
606 horizontal: "\u{2550}",
607 ..frame
608 },
609 &widths,
610 ),
611 _ => rule.clone(),
612 });
613 for row in rows {
614 let cells: Vec<String> = row
615 .iter()
616 .enumerate()
617 .map(|(index, value)| align(layout, value, widths.get(index).copied().unwrap_or(0)))
618 .collect();
619 out.push(drawn_row(&frame, &cells, &widths, layout, false));
620 }
621 if layout.mode == Mode::Box {
622 out.push(rule_line(
623 &Frame {
624 left: "\u{2570}",
625 middle: "\u{2534}",
626 right: "\u{256f}",
627 ..frame
628 },
629 &widths,
630 ));
631 } else if layout.mode != Mode::Markdown {
632 out.push(rule);
633 }
634 out
635}
636
637fn rule_line(frame: &Frame, widths: &[usize]) -> String {
639 let parts: Vec<String> = widths
640 .iter()
641 .map(|width| frame.horizontal.repeat(width + 2))
642 .collect();
643 format!("{}{}{}", frame.left, parts.join(frame.middle), frame.right)
644}
645
646fn markdown_rule(widths: &[usize]) -> String {
648 let parts: Vec<String> = widths.iter().map(|width| "-".repeat(width + 2)).collect();
649 format!("|{}|", parts.join("|"))
650}
651
652fn drawn_row(
654 frame: &Frame,
655 cells: &[String],
656 widths: &[usize],
657 layout: &Layout,
658 header: bool,
659) -> String {
660 let _ = (layout, header);
661 let padded: Vec<String> = widths
662 .iter()
663 .enumerate()
664 .map(|(index, width)| {
665 let cell = cells.get(index).cloned().unwrap_or_default();
666 format!(" {cell:<width$} ")
667 })
668 .collect();
669 format!(
670 "{}{}{}",
671 frame.vertical,
672 padded.join(frame.vertical),
673 frame.vertical
674 )
675}
676
677fn html(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
683 let mut out = Vec::new();
684 if layout.headers {
685 out.push("<TR>".to_string());
686 for name in columns {
687 out.push(format!("<TH>{}", html_escape(name)));
688 }
689 out.push("</TR>".to_string());
690 }
691 for row in rows {
692 out.push("<TR>".to_string());
693 for value in row {
694 out.push(format!("<TD>{}", html_escape(&plain(layout, value))));
695 }
696 out.push("</TR>".to_string());
697 }
698 out
699}
700
701fn html_escape(text: &str) -> String {
703 text.replace('&', "&")
704 .replace('<', "<")
705 .replace('>', ">")
706 .replace('"', """)
707}
708
709#[cfg(test)]
710mod tests {
711 use super::*;
712
713 fn sample() -> (Vec<String>, Vec<Vec<Value<'static>>>) {
715 let columns = vec!["a".to_string(), "b".to_string()];
716 let rows = vec![vec![
717 Value::Integer(1),
718 Value::owned_text(b"two").expect("owns"),
719 ]];
720 (columns, rows)
721 }
722
723 #[test]
725 fn the_default_is_a_pipe_separated_line() {
726 let (columns, rows) = sample();
727 let out = render(&Layout::default(), &columns, &rows);
728 assert_eq!(out, vec!["1|two"]);
729 }
730
731 #[test]
733 fn headers_use_the_same_layout() {
734 let (columns, rows) = sample();
735 let layout = Layout {
736 headers: true,
737 ..Layout::default()
738 };
739 let out = render(&layout, &columns, &rows);
740 assert_eq!(out, vec!["a|b", "1|two"]);
741 }
742
743 #[test]
753 fn a_csv_record_ends_the_way_its_destination_expects() {
754 let (columns, rows) = sample();
755 let to_a_file = Layout {
756 mode: Mode::Csv,
757 separator: ",".to_string(),
758 row_separator: "\r\n".to_string(),
759 to_stdout: false,
760 ..Layout::default()
761 };
762 assert_eq!(render(&to_a_file, &columns, &rows), vec!["1,two\r"]);
763 let to_the_terminal = Layout {
764 to_stdout: true,
765 ..to_a_file
766 };
767 let expected = if cfg!(windows) {
768 "1,two\r\r"
769 } else {
770 "1,two\r"
771 };
772 assert_eq!(render(&to_the_terminal, &columns, &rows), vec![expected]);
773 }
774
775 #[test]
777 fn csv_quotes_only_what_it_must() {
778 assert_eq!(csv_field("plain"), "plain");
779 assert_eq!(csv_field("a,b"), "\"a,b\"");
780 assert_eq!(csv_field("say \"hi\""), "\"say \"\"hi\"\"\"");
781 }
782
783 #[test]
792 fn csv_tells_an_empty_value_from_a_null() {
793 let columns = vec!["x".to_string()];
794 let rows = vec![
795 vec![Value::Null],
796 vec![Value::owned_blob(&[0, 1, 2]).expect("owns")],
797 vec![Value::owned_text(b"").expect("owns")],
798 vec![Value::owned_text(b"kept").expect("owns")],
799 ];
800 let layout = Layout {
801 mode: Mode::Csv,
802 separator: ",".to_string(),
803 ..Layout::default()
804 };
805 assert_eq!(
806 render(&layout, &columns, &rows),
807 vec!["", "\"\"", "\"\"", "kept"]
808 );
809 }
810
811 #[test]
813 fn control_characters_are_escaped() {
814 assert_eq!(printable(b"ab"), "ab");
815 assert_eq!(printable(&[0x01, 0x02]), "^A^B");
816 assert_eq!(printable(&[0x09, b't']), "^It");
817 assert_eq!(printable(&[0x7f]), "^?");
818 assert_eq!(printable(b"a\nb"), "a\nb");
819 assert_eq!(printable(&[b'a', 0, b'b']), "a");
820 }
821
822 #[test]
824 fn quote_mode_writes_literals() {
825 let columns = vec!["x".to_string()];
826 let rows = vec![
827 vec![Value::Null],
828 vec![Value::owned_blob(&[1, 255]).expect("owns")],
829 vec![Value::owned_text(b"it's").expect("owns")],
830 ];
831 let layout = Layout {
832 mode: Mode::Quote,
833 ..Layout::default()
834 };
835 let out = render(&layout, &columns, &rows);
836 assert_eq!(out, vec!["NULL", "x'01ff'", "'it''s'"]);
837 }
838
839 #[test]
841 fn every_mode_name_round_trips() {
842 for mode in [
843 Mode::List,
844 Mode::Column,
845 Mode::Line,
846 Mode::Csv,
847 Mode::Tabs,
848 Mode::Quote,
849 Mode::Insert,
850 Mode::Json,
851 Mode::Markdown,
852 Mode::Table,
853 Mode::Box,
854 Mode::Html,
855 ] {
856 assert_eq!(Mode::from_name(mode.name()), Some(mode), "{}", mode.name());
857 }
858 assert_eq!(Mode::from_name("nonsense"), None);
859 }
860
861 #[test]
863 fn a_table_is_drawn_with_rules() {
864 let (columns, rows) = sample();
865 let layout = Layout {
866 mode: Mode::Table,
867 headers: true,
868 ..Layout::default()
869 };
870 let out = render(&layout, &columns, &rows);
871 assert_eq!(out.len(), 5, "{out:#?}");
872 assert!(out.first().is_some_and(|line| line.starts_with('+')));
873 assert!(out.last().is_some_and(|line| line.starts_with('+')));
874 }
875
876 #[test]
878 fn the_shell_escapes_through_the_base_crate() {
879 assert_eq!(inillucent_base::json::escape("a\"b"), "a\\\"b");
880 assert_eq!(inillucent_base::json::escape("a\nb"), "a\\nb");
881 assert_eq!(inillucent_base::json::escape("a\u{1}b"), "a\\u0001b");
882 assert_eq!(inillucent_base::json::escape("a\u{8}b"), "a\\bb");
885 assert_eq!(inillucent_base::json::escape("a\u{c}b"), "a\\fb");
886 assert_eq!(inillucent_base::json::escape("a\u{7f}b"), "a\\u007fb");
887 }
888}