Skip to main content

inillucent_cli/
render.rs

1//! The output modes: how a row becomes the text a person or a script reads.
2//!
3//! Invariant: a mode decides layout and nothing else. Every mode is handed the
4//! same values, and none of them converts one - a blob prints as its bytes in
5//! `list` and as an `x'...'` literal in `quote` because those are two ways of
6//! writing the same value, not two values. The conversions themselves belong to
7//! the engine, which is why nothing in this file parses or casts anything.
8//!
9//! The default is SQLite's: `list` mode, `|` between columns, headers off, and
10//! NULL as the empty string. That last one is a genuinely bad default and it is
11//! kept anyway, because a script written against `sqlite3` and pointed at this
12//! shell has to see the same bytes.
13
14use inillucent_value::Value;
15
16/// How rows are laid out.
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
18pub enum Mode {
19    /// Columns separated by the separator, one row per line.
20    #[default]
21    List,
22    /// Fixed-width columns, padded to the widest value.
23    Column,
24    /// One `name = value` line per column, a blank line between rows.
25    Line,
26    /// Comma-separated, quoted the way a spreadsheet expects.
27    Csv,
28    /// Columns separated by tabs.
29    Tabs,
30    /// Every value as an SQL literal.
31    Quote,
32    /// One `INSERT INTO` statement per row.
33    Insert,
34    /// A JSON array of objects.
35    Json,
36    /// A Markdown table.
37    Markdown,
38    /// A table drawn with `+` and `-`.
39    Table,
40    /// A table drawn with box-drawing characters.
41    Box,
42    /// An HTML table body.
43    Html,
44}
45
46impl Mode {
47    /// Returns the mode a name selects.
48    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    /// Returns the column separator this mode starts with.
67    ///
68    /// Choosing a mode resets the separator, which is why `.mode csv` produces
69    /// commas without being told to. A `.separator` afterwards still wins.
70    pub fn separator(self) -> &'static str {
71        match self {
72            Mode::Csv | Mode::Quote => ",",
73            Mode::Tabs => "\t",
74            _ => "|",
75        }
76    }
77
78    /// Returns the name `.show` prints for this mode.
79    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/// Everything a mode needs that is not the rows themselves.
98#[derive(Clone, Debug)]
99pub struct Layout {
100    /// Which mode.
101    pub mode: Mode,
102    /// What goes between columns, in the modes that use one.
103    pub separator: String,
104    /// What goes between rows.
105    pub row_separator: String,
106    /// What a NULL prints as.
107    pub null: String,
108    /// Whether to print a header line.
109    pub headers: bool,
110    /// The table name `insert` mode writes.
111    pub table: String,
112    /// The column widths `.width` fixed, if any.
113    pub widths: Vec<usize>,
114    /// Whether these lines are going to standard output.
115    ///
116    /// **Only `csv` reads it, and only on Windows**, where standard output is
117    /// the one destination that translates a line feed on the way out. The
118    /// bytes are in the comment at the end of [`csv`]. A file and a collecting
119    /// caller both receive exactly what is written to them, so the second
120    /// carriage return that makes standard output match the reference is wrong
121    /// for both. The shell sets this from where its own output is currently
122    /// going; the default is standard output, because that is where a `Layout`
123    /// built by hand is printed.
124    pub to_stdout: bool,
125}
126
127impl Default for Layout {
128    /// Returns SQLite's defaults.
129    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            // "tab", not "table": the reference chose a name that is not
137            // a keyword, so the statements it writes can be pasted back.
138            table: "tab".to_string(),
139            widths: Vec::new(),
140            to_stdout: true,
141        }
142    }
143}
144
145/// Renders a result set into the lines that should be printed.
146///
147/// It returns lines rather than writing, so the caller decides where they go -
148/// which is what `.output` and `.once` need, and what makes this testable
149/// without a file.
150///
151/// @param layout - the output settings
152/// @param columns - the result's column names
153/// @param rows - the result's rows
154pub 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
168/// Returns the text a value prints as in the plain modes.
169fn 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
179/// Returns bytes as the shell prints them.
180///
181/// Two rules, both the reference's. A value is printed as a C string, so it
182/// stops at the first NUL; and a control character is printed in caret
183/// notation, because a shell that emitted raw control bytes could be made to
184/// drive a terminal by the contents of a database. A newline is left alone,
185/// since a multi-line value is meant to look like one.
186fn 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
209/// Returns the text the engine writes a number as.
210fn 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
222/// Returns the SQL literal a value would be written as.
223pub 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            // Lower case, both the `x` and the digits: it is what the reference
234            // writes, and a dump is compared against one.
235            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
245/// Returns a value as `.mode quote` and `.mode insert` write it.
246///
247/// **A text with a control character is written as `unistr('...')`.** The reference's
248/// `output_quoted_string` counts the characters below U+0020, and when there is one
249/// it cannot print the text between plain quotes without a newline, a tab or an escape
250/// sequence landing in the output, so it writes a call to `unistr()` that rebuilds the
251/// text: a quote is doubled, a backslash is doubled, and each control character is a
252/// `\uXXXX` escape in lower case hex. Without a control character the text is written
253/// between plain quotes, backslash and all, and DEL and the C1 range are not controls.
254/// The text ends at its first NUL byte, because the reference reads it as a C string.
255/// Everything that is not text is written as `literal` writes it.
256///
257/// `.dump` and the other callers of `literal` are unchanged: the reference builds those
258/// with the SQL `quote()` function, which does not escape.
259///
260/// @param value - the value
261pub 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
289/// Returns a column name as `.mode quote` writes it in the header line.
290///
291/// @param name - the column name
292fn quote_name(name: &str) -> String {
293    quote_literal(&Value::owned_text(name.as_bytes()).unwrap_or(Value::Null))
294}
295
296/// `list` and `tabs`: values with a separator between them.
297fn 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
314/// `csv`: a field is quoted when it holds a comma, a quote or a newline.
315fn 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    // The caller writes a newline after each line, so a row separator of
331    // CR LF is a carriage return on the end of the line itself.
332    //
333    // **And a second one, but only on standard output, and only on Windows.**
334    // The reference writes its CR LF through a text-mode C stream, which
335    // translates the LF into CR LF again on the way out, and Rust's `write!`
336    // does no such translation - so emitting two bytes where the reference
337    // emits three would not be byte-compatible with the thing this replaces.
338    //
339    // The destination decides, because the reference's destinations differ.
340    // Measured against the pinned 3.53.4 shell on Windows, one row of one
341    // table, `.mode csv` with headers on:
342    //
343    // | what the reference was asked for | bytes at the end of a record |
344    // |---|---|
345    // | `sqlite3 -csv -header db "SELECT..."`, standard output | CR CR LF |
346    // | the same rows through `.once out.csv`, in the file | CR LF |
347    //
348    // Its output file is not a text-mode stream, so the file gets RFC 4180's
349    // CR LF and nothing more. This shell wrote CR CR LF into the file and into
350    // the text a collecting caller reads, which matched neither. That is what
351    // `to_stdout` is for.
352    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
363/// The row separator RFC 4180 gives a CSV record, and the reference writes.
364const CRLF: &str = "\r\n";
365
366/// The carriage return half of it.
367const CR: char = '\r';
368
369/// Renders one value as a CSV field.
370///
371/// @param layout - the mode's settings, for `nullvalue`
372/// @param value - the cell
373fn csv_cell(layout: &Layout, value: &Value<'static>) -> String {
374    let text = plain(layout, value);
375    // **An empty field that is not NULL is quoted, which is how the two are
376    // told apart** (task-2066 section 4.2, item 27). A BLOB is printed as a C
377    // string and stops at its first NUL, so a blob beginning with one rendered
378    // as nothing at all - and so does a NULL under the default `nullvalue`,
379    // which is the empty string. The reference writes two quotes for the
380    // first and nothing for the second, so an exported blob could be read back
381    // as a NULL.
382    if text.is_empty() && !value.is_null() {
383        return "\"\"".to_string();
384    }
385    csv_field(&text)
386}
387
388/// Quotes one CSV field, if it needs it.
389///
390/// The separator, a quote and a line break all force quoting, and so does a
391/// control character - it has already been turned into caret notation by the
392/// time this sees it, and quoting is how the reference marks that the field was
393/// not plain text to begin with.
394///
395/// @param text - the rendered field
396fn 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
408/// `quote`: every value as the literal it would be written as.
409fn 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
427/// `line`: one `name: value` per column, rows separated by a blank line.
428fn 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
447/// `insert`: one statement per row, which is what a dump is made of.
448///
449/// With headers on, SQLite names the columns after the table, each quoted only
450/// when its `quoteChar` says so: a name that is not a plain word, or that is a
451/// keyword.
452fn 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
467/// Returns a column name as SQLite's shell writes it in `.mode insert`.
468///
469/// The rule is `quoteChar` in the shell's source: quote when the first
470/// character is not a letter or an underscore, when any character is not a
471/// letter, digit or underscore, or when the word is a keyword.
472///
473/// @param name - the column name
474fn 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
489/// `json`: an array of objects, one per row.
490fn json(layout: &Layout, columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
491    let _ = layout;
492    if rows.is_empty() {
493        // An empty result prints nothing, not an empty array: the reference
494        // writes the brackets around rows and there are none.
495        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
518/// Returns a value as JSON.
519fn 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        // A blob's bytes, each as its own escape: the reference writes
529        // `"\u00ab"` rather than the hex a reader might expect, and a consumer
530        // of the JSON is reading whichever one it was given.
531        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
542/// Returns each column's width: the widest of its values, and of its name
543/// when the name is printed. With headers off SQLite sizes a column by its
544/// values alone.
545fn 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
576/// `column`: fixed-width columns with two spaces between them.
577fn 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
606/// Returns one cell padded to its width, right-aligned when it is a number.
607///
608/// A number is right-aligned and everything else is not, which is what makes a
609/// column of amounts line up on its digits.
610fn 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
618/// Centres text in a field, leaning left when it cannot be even.
619///
620/// Every tabular mode centres its headers over left-aligned values, which is
621/// the reference's choice and looks better than it sounds: a narrow numeric
622/// column under a long name is unreadable left-aligned.
623fn 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
633/// Pads a row of cells to the given widths, trimming the trailing run.
634fn 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
646/// The characters one drawn table is made of.
647struct Frame {
648    left: &'static str,
649    middle: &'static str,
650    right: &'static str,
651    horizontal: &'static str,
652    vertical: &'static str,
653}
654
655/// `markdown`, `table` and `box`: a header, a rule, and the rows.
656fn 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        // The reference draws its box with rounded corners and a double
667        // rule under the header. That is copied exactly rather than
668        // approximated: the whole value of the mode is that a person
669        // recognises the output.
670        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
717/// Appends the header line of a drawn table and the rule under it.
718///
719/// With headers off SQLite draws neither, and a box or a table keeps only
720/// its top and bottom rules.
721///
722/// @param layout - the shell's output settings
723/// @param frame - the characters the mode draws with
724/// @param columns - the column names
725/// @param widths - each column's width
726/// @param rule - the mode's plain rule, which `table` repeats under the header
727/// @param out - the lines drawn so far
728fn 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, &centred, 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
758/// Returns one horizontal rule.
759fn 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
767/// Returns the `|---|---|` line Markdown wants under its header.
768fn markdown_rule(widths: &[usize]) -> String {
769    let parts: Vec<String> = widths.iter().map(|width| "-".repeat(width + 2)).collect();
770    format!("|{}|", parts.join("|"))
771}
772
773/// Returns one drawn row, padded to the widths.
774fn 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
798/// `html`: a table body, which is what a caller pastes into a page.
799///
800/// One cell per line and no closing cell tags, which is what the reference
801/// emits. It is valid HTML - a `<TD>` closes the one before it - and it is what
802/// a diff against the reference has to produce.
803fn 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
822/// Escapes the four characters that mean something in HTML.
823fn html_escape(text: &str) -> String {
824    text.replace('&', "&amp;")
825        .replace('<', "&lt;")
826        .replace('>', "&gt;")
827        .replace('"', "&quot;")
828}
829
830#[cfg(test)]
831mod tests {
832    use super::*;
833
834    /// Builds a one-row result for the tests below.
835    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    /// The default mode is SQLite's: pipes, no headers.
845    #[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    /// Headers are the column names, in the same layout as the rows.
853    #[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    /// A CSV record ends with one carriage return unless it is going to
865    /// standard output on Windows, where it ends with two.
866    ///
867    /// The two counts are the reference's, measured at 3.53.4: `sqlite3 -csv`
868    /// emits CR CR LF on standard output on this platform, because its row
869    /// separator is CR LF and the C stream translates the LF again; the same
870    /// rows through `.once out.csv` hold CR LF, because the file is not such a
871    /// stream. Both destinations are checked here so that a change to either
872    /// one has to be a deliberate change to this case.
873    #[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    /// A CSV field is quoted only when it has to be.
897    #[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    /// **An empty CSV field that is not NULL is quoted, and a NULL is not.**
905    ///
906    /// A blob is printed as a C string and stops at its first NUL, so a blob
907    /// beginning with one rendered as nothing at all - and so does a NULL
908    /// under the default `nullvalue`, which is the empty string. The reference
909    /// writes two quotes for the first and nothing for the second, so an
910    /// exported blob was indistinguishable from a NULL on the way back in
911    /// (task-2066 section 4.2, item 27).
912    #[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    /// A control character is escaped and a NUL ends the value.
933    #[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    /// Quote mode writes values as SQL literals, blobs included.
944    #[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    /// Every mode name round-trips.
961    #[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    /// A drawn table has a rule above and below its rows.
983    #[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    /// JSON escapes what JSON has to escape.
998    #[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        // The two the private copy spelled as `\u0008` and `\u000c`, and the
1004        // one it left unescaped.
1005        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}