Skip to main content

jay/
fmt.rs

1//! Human-readable array formatting, J session style: numeric columns
2//! aligned, higher-rank arrays printed as planes separated by blank lines.
3
4use crate::array::{Array, Data};
5use crate::dtype::DType;
6
7/// How a boxed array is drawn.
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum BoxStyle {
10    /// J: a table of cells fenced with `+`, `-` and `|`.
11    Fenced,
12    /// APL: the cells side by side, one space between them and one around
13    /// the whole. GNU APL spaces a nested display more widely than this;
14    /// see docs/coverage.md.
15    Spaced,
16}
17
18/// Display conventions that differ between languages.
19#[derive(Clone, Copy, Debug)]
20pub struct FmtOpts {
21    /// Negative-number prefix: `_` for J, `¯` for APL.
22    pub neg: char,
23    /// Separator between the parts of a complex number: `j` for J, `J` for
24    /// APL.
25    pub imag: char,
26    pub boxes: BoxStyle,
27}
28
29impl FmtOpts {
30    pub const J: FmtOpts = FmtOpts { neg: '_', imag: 'j', boxes: BoxStyle::Fenced };
31    pub const APL: FmtOpts = FmtOpts { neg: '¯', imag: 'J', boxes: BoxStyle::Spaced };
32}
33
34/// Significant digits kept when displaying a float.
35const SIG_DIGITS: usize = 6;
36
37/// Format an array for display. No trailing newline.
38pub fn format_array(a: &Array, opts: &FmtOpts) -> String {
39    // An array with an empty axis has nothing to show.
40    if a.shape.contains(&0) {
41        return String::new();
42    }
43    if a.dtype() == DType::Box {
44        // A boxed array whose every element is a simple scalar is APL's
45        // MIXED SIMPLE array: depth 1, and drawn the way a plain array is
46        // rather than with a nested display's extra spacing.
47        match mixed_simple_texts(a, opts) {
48            Some(texts) if opts.boxes == BoxStyle::Spaced => {
49                return laid_out(&a.shape, texts, false)
50            }
51            _ => return format_boxed(a, opts),
52        }
53    }
54    let texts: Vec<String> = (0..a.count()).map(|i| format_atom(&a.data, i, opts)).collect();
55    // Characters are laid out as text lines; everything else as columns.
56    laid_out(&a.shape, texts, a.dtype() == DType::Char)
57}
58
59/// The scalar each element of a boxed array holds, where every one of them
60/// holds a simple scalar and nothing else.
61fn mixed_simple_texts(a: &Array, opts: &FmtOpts) -> Option<Vec<String>> {
62    let boxes = a.as_boxes()?;
63    let mut texts = Vec::with_capacity(boxes.len());
64    for b in boxes {
65        if b.rank() != 0 || b.dtype() == DType::Box {
66            return None;
67        }
68        texts.push(format_atom(&b.data, 0, opts));
69    }
70    Some(texts)
71}
72
73/// One formatted element per position, laid out for the shape: a vector on
74/// one line, higher ranks as aligned columns and planes.
75fn laid_out(shape: &[usize], texts: Vec<String>, text_layout: bool) -> String {
76    let rank = shape.len();
77    let a_shape = shape;
78    match rank {
79        0 => texts.into_iter().next().unwrap_or_default(),
80        1 if text_layout => texts.concat(),
81        1 => texts.join(" "),
82        _ => {
83            let ncols = a_shape[rank - 1];
84            let nrows = a_shape[rank - 2];
85            // Column widths span every plane, so planes stay aligned with
86            // each other and not just internally.
87            let widths = if text_layout { vec![0; ncols] } else { column_widths(&texts, ncols) };
88            let frame = &a_shape[..rank - 2];
89            let plane_size = nrows * ncols;
90            let planes: usize = frame.iter().product();
91            let mut out = String::new();
92            for p in 0..planes {
93                if p > 0 {
94                    // One newline ends the previous line, the rest are blanks.
95                    out.push_str(&"\n".repeat(plane_gap(frame, p) + 1));
96                }
97                for r in 0..nrows {
98                    if r > 0 {
99                        out.push('\n');
100                    }
101                    let start = p * plane_size + r * ncols;
102                    push_row(&mut out, &texts[start..start + ncols], &widths, text_layout);
103                }
104            }
105            out
106        }
107    }
108}
109
110/// A boxed array as its language draws it: the last two axes form a table
111/// of cells, each holding its own contents' display, and the axes above
112/// them separate planes exactly as they do for numbers.
113fn format_boxed(a: &Array, opts: &FmtOpts) -> String {
114    let boxes = a.as_boxes().expect("boxed data");
115    let blocks: Vec<Vec<String>> = boxes.iter().map(|b| block(b, opts)).collect();
116    let rank = a.rank();
117    let (nrows, ncols) = match rank {
118        0 => (1, 1),
119        1 => (1, a.shape[0]),
120        _ => (a.shape[rank - 2], a.shape[rank - 1]),
121    };
122    // Column widths span the whole array, as they do for numeric columns.
123    let mut widths = vec![0usize; ncols];
124    for (i, b) in blocks.iter().enumerate() {
125        let w = b.iter().map(|l| width(l)).max().unwrap_or(0);
126        widths[i % ncols] = widths[i % ncols].max(w);
127    }
128    let frame: &[usize] = if rank > 2 { &a.shape[..rank - 2] } else { &[] };
129    let planes: usize = frame.iter().product();
130    let plane_size = nrows * ncols;
131    let mut out = String::new();
132    for p in 0..planes.max(1) {
133        if p > 0 {
134            out.push_str(&"\n".repeat(plane_gap(frame, p) + 1));
135        }
136        push_boxed_plane(
137            &mut out,
138            &blocks[p * plane_size..(p + 1) * plane_size],
139            nrows,
140            ncols,
141            &widths,
142            opts,
143        );
144    }
145    out
146}
147
148/// One box's contents as display lines. An empty array shows as a single
149/// empty line, which is what gives `<''` a cell of width zero rather than
150/// no cell at all.
151fn block(a: &Array, opts: &FmtOpts) -> Vec<String> {
152    let text = format_array(a, opts);
153    if text.is_empty() {
154        return vec![String::new()];
155    }
156    text.lines().map(str::to_string).collect()
157}
158
159fn push_boxed_plane(
160    out: &mut String,
161    blocks: &[Vec<String>],
162    nrows: usize,
163    ncols: usize,
164    widths: &[usize],
165    opts: &FmtOpts,
166) {
167    let fence = opts.boxes == BoxStyle::Fenced;
168    let border: String = if fence {
169        let mut s = String::from("+");
170        for &w in widths {
171            s.push_str(&"-".repeat(w));
172            s.push('+');
173        }
174        s
175    } else {
176        String::new()
177    };
178    let mut lines: Vec<String> = Vec::new();
179    for r in 0..nrows {
180        if fence {
181            lines.push(border.clone());
182        }
183        let row = &blocks[r * ncols..(r + 1) * ncols];
184        // A row is as tall as its tallest cell; the others are padded
185        // underneath, which is where J puts the blanks.
186        let height = row.iter().map(Vec::len).max().unwrap_or(1);
187        for k in 0..height {
188            let mut line = String::new();
189            line.push(if fence { '|' } else { ' ' });
190            for (c, cell) in row.iter().enumerate() {
191                if !fence && c > 0 {
192                    line.push(' ');
193                }
194                let text = cell.get(k).map(String::as_str).unwrap_or("");
195                line.push_str(text);
196                for _ in 0..widths[c].saturating_sub(width(text)) {
197                    line.push(' ');
198                }
199                if fence {
200                    line.push('|');
201                }
202            }
203            if !fence {
204                line.push(' ');
205            }
206            lines.push(line);
207        }
208    }
209    if fence {
210        lines.push(border);
211    }
212    out.push_str(&lines.join("\n"));
213}
214
215/// Blank lines before plane `p`: one for a step along axis -3, two along
216/// axis -4, and so on. `frame` is the shape without its last two axes.
217fn plane_gap(frame: &[usize], p: usize) -> usize {
218    // The step size is one plus the number of trailing odometer digits of
219    // `p` that have just rolled over to zero.
220    let mut gap = 1;
221    let mut rest = p;
222    for &n in frame.iter().rev() {
223        if rest % n != 0 {
224            break;
225        }
226        rest /= n;
227        gap += 1;
228    }
229    gap
230}
231
232/// Widest formatted element per column index, taken over the whole array.
233fn column_widths(texts: &[String], ncols: usize) -> Vec<usize> {
234    let mut widths = vec![0usize; ncols];
235    for (i, t) in texts.iter().enumerate() {
236        let j = i % ncols;
237        widths[j] = widths[j].max(width(t));
238    }
239    widths
240}
241
242fn push_row(out: &mut String, row: &[String], widths: &[usize], text_layout: bool) {
243    for (j, cell) in row.iter().enumerate() {
244        if text_layout {
245            out.push_str(cell);
246            continue;
247        }
248        if j > 0 {
249            out.push(' ');
250        }
251        for _ in 0..widths[j].saturating_sub(width(cell)) {
252            out.push(' ');
253        }
254        out.push_str(cell);
255    }
256}
257
258/// Display width in characters; the APL minus sign is multi-byte.
259fn width(s: &str) -> usize {
260    s.chars().count()
261}
262
263fn format_atom(data: &Data, i: usize, opts: &FmtOpts) -> String {
264    match data {
265        Data::Bool(v) => (if v[i] != 0 { "1" } else { "0" }).to_string(),
266        Data::I64(v) => format_i64(v[i], opts),
267        Data::Ext(v) => with_neg_sign(&v[i].to_string(), opts),
268        Data::Rat(v) => with_neg_sign(&v[i].to_string(), opts),
269        Data::F64(v) => format_f64(v[i], opts),
270        Data::Complex(v) => format_complex(v[i], opts),
271        Data::Char(v) => v[i].to_string(),
272        // Boxed data takes the drawing path before reaching here.
273        Data::Box(_) => String::new(),
274    }
275}
276
277/// A complex number, as both references print one: the two parts joined by
278/// `j`/`J`, and the real part alone when the imaginary part is exactly zero.
279/// The demotion is in the display only — the value keeps its complex type,
280/// which is what `3!:0` reports of it in J.
281fn format_complex(z: crate::complex::Cx, opts: &FmtOpts) -> String {
282    if z[1] == 0.0 {
283        return format_f64(z[0], opts);
284    }
285    format!("{}{}{}", format_f64(z[0], opts), opts.imag, format_f64(z[1], opts))
286}
287
288fn format_i64(v: i64, opts: &FmtOpts) -> String {
289    with_neg_sign(&v.to_string(), opts)
290}
291
292/// A Rust-formatted number with its leading `-` replaced by the language’s
293/// own negative sign. An extended integer and a rational both arrive here
294/// already spelled the way J spells them (`123`, `_1r2` once the sign is
295/// swapped), so nothing else has to be rewritten.
296fn with_neg_sign(s: &str, opts: &FmtOpts) -> String {
297    match s.strip_prefix('-') {
298        Some(rest) => with_sign(rest, opts),
299        None => s.to_string(),
300    }
301}
302
303fn format_f64(x: f64, opts: &FmtOpts) -> String {
304    if x.is_nan() {
305        return format!("{}.", opts.neg);
306    }
307    if x.is_infinite() {
308        // J spells the infinities `_` and `__`; APL has no standard glyph.
309        return match (opts.neg, x > 0.0) {
310            ('_', true) => "_".to_string(),
311            ('_', false) => "__".to_string(),
312            (_, true) => "∞".to_string(),
313            (neg, false) => format!("{neg}∞"),
314        };
315    }
316    let magnitude = x.abs();
317    // Round to `SIG_DIGITS` first, then decide how to spell the result;
318    // scientific formatting hands us the digits and the exponent directly.
319    let sci = format!("{:.*e}", SIG_DIGITS - 1, magnitude);
320    let (mantissa, exponent) = sci.split_once('e').expect("scientific form has an exponent");
321    let digits: String = mantissa.chars().filter(char::is_ascii_digit).collect();
322    let exponent: i32 = exponent.parse().expect("exponent is an integer");
323    let body = if exponent >= 12 || exponent <= -6 {
324        let mut s = trim_fraction(&place_point(&digits, 1));
325        s.push('e');
326        if exponent < 0 {
327            s.push_str(&with_sign(&(-(exponent as i64)).to_string(), opts));
328        } else {
329            s.push_str(&exponent.to_string());
330        }
331        s
332    } else {
333        positional(&digits, exponent)
334    };
335    if x < 0.0 { with_sign(&body, opts) } else { body }
336}
337
338/// `digits` written out with the decimal point implied by `exponent`.
339fn positional(digits: &str, exponent: i32) -> String {
340    if exponent < 0 {
341        let zeros = (-exponent - 1) as usize;
342        return trim_fraction(&format!("0.{}{}", "0".repeat(zeros), digits));
343    }
344    let int_len = exponent as usize + 1;
345    if int_len >= digits.len() {
346        // Rounding put the last significant digit left of the point; the
347        // padding zeros carry magnitude, so there is nothing to trim.
348        return format!("{}{}", digits, "0".repeat(int_len - digits.len()));
349    }
350    trim_fraction(&place_point(digits, int_len))
351}
352
353/// Insert a decimal point after `int_len` digits.
354fn place_point(digits: &str, int_len: usize) -> String {
355    format!("{}.{}", &digits[..int_len], &digits[int_len..])
356}
357
358/// Drop trailing fraction zeros, then a bare trailing point.
359fn trim_fraction(s: &str) -> String {
360    if !s.contains('.') {
361        return s.to_string();
362    }
363    s.trim_end_matches('0').trim_end_matches('.').to_string()
364}
365
366fn with_sign(body: &str, opts: &FmtOpts) -> String {
367    let mut s = String::with_capacity(body.len() + opts.neg.len_utf8());
368    s.push(opts.neg);
369    s.push_str(body);
370    s
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use crate::array::Buf;
377    use rstest::rstest;
378
379    fn j(a: &Array) -> String {
380        format_array(a, &FmtOpts::J)
381    }
382
383    fn apl(a: &Array) -> String {
384        format_array(a, &FmtOpts::APL)
385    }
386
387    fn fj(x: f64) -> String {
388        format_f64(x, &FmtOpts::J)
389    }
390
391    // Atoms.
392
393    #[rstest]
394    #[case(0, "0")]
395    #[case(7, "7")]
396    #[case(-3, "_3")]
397    #[case(-1234, "_1234")]
398    #[case(i64::MIN, "_9223372036854775808")]
399    fn integers_j(#[case] v: i64, #[case] want: &str) {
400        assert_eq!(format_i64(v, &FmtOpts::J), want);
401    }
402
403    #[rstest]
404    #[case(-3, "¯3")]
405    #[case(3, "3")]
406    fn integers_apl(#[case] v: i64, #[case] want: &str) {
407        assert_eq!(format_i64(v, &FmtOpts::APL), want);
408    }
409
410    #[rstest]
411    #[case(0.0, "0")]
412    #[case(0.5, "0.5")]
413    #[case(2.0, "2")]
414    #[case(-2.0, "_2")]
415    #[case(1.0 / 3.0, "0.333333")]
416    #[case(-1.0 / 3.0, "_0.333333")]
417    #[case(2.0 / 3.0, "0.666667")]
418    #[case(1.25, "1.25")]
419    #[case(100.0, "100")]
420    #[case(1e-5, "0.00001")]
421    #[case(0.000012345678, "0.0000123457")]
422    #[case(1e11, "100000000000")]
423    #[case(123456789.0, "123457000")]
424    fn floats_positional(#[case] x: f64, #[case] want: &str) {
425        assert_eq!(fj(x), want);
426    }
427
428    #[rstest]
429    #[case(1e-7, "1e_7")]
430    #[case(-1e-7, "_1e_7")]
431    #[case(1.5e13, "1.5e13")]
432    #[case(1e12, "1e12")]
433    #[case(-2.5e20, "_2.5e20")]
434    #[case(1.234567e-9, "1.23457e_9")]
435    fn floats_exponent(#[case] x: f64, #[case] want: &str) {
436        assert_eq!(fj(x), want);
437    }
438
439    #[test]
440    fn floats_apl_signs() {
441        assert_eq!(format_f64(-0.5, &FmtOpts::APL), "¯0.5");
442        assert_eq!(format_f64(1e-7, &FmtOpts::APL), "1e¯7");
443        assert_eq!(format_f64(-1e-7, &FmtOpts::APL), "¯1e¯7");
444    }
445
446    #[test]
447    fn negative_zero_prints_unsigned() {
448        assert_eq!(fj(-0.0), "0");
449    }
450
451    #[test]
452    fn nan_and_infinities() {
453        assert_eq!(fj(f64::NAN), "_.");
454        assert_eq!(fj(f64::INFINITY), "_");
455        assert_eq!(fj(f64::NEG_INFINITY), "__");
456        assert_eq!(format_f64(f64::NAN, &FmtOpts::APL), "¯.");
457        assert_eq!(format_f64(f64::INFINITY, &FmtOpts::APL), "∞");
458        assert_eq!(format_f64(f64::NEG_INFINITY, &FmtOpts::APL), "¯∞");
459    }
460
461    #[test]
462    fn scalars() {
463        assert_eq!(j(&Array::scalar_i64(-3)), "_3");
464        assert_eq!(apl(&Array::scalar_i64(-3)), "¯3");
465        assert_eq!(j(&Array::scalar_f64(0.5)), "0.5");
466        assert_eq!(j(&Array::scalar_bool(true)), "1");
467        assert_eq!(j(&Array::scalar_bool(false)), "0");
468        assert_eq!(j(&Array::new(vec![], Data::Char(vec!['q'].into()))), "q");
469    }
470
471    // Vectors.
472
473    #[test]
474    fn integer_vector() {
475        let a = Array::from_i64(vec![1, -22, 333]);
476        assert_eq!(j(&a), "1 _22 333");
477        assert_eq!(apl(&a), "1 ¯22 333");
478    }
479
480    #[test]
481    fn float_vector_trims_independently() {
482        let a = Array::from_f64(vec![0.5, 2.0, 1.0 / 3.0, -1e-7]);
483        assert_eq!(j(&a), "0.5 2 0.333333 _1e_7");
484    }
485
486    #[test]
487    fn bool_vector() {
488        let a = Array::new(vec![4], Data::Bool(vec![1, 0, 0, 1].into()));
489        assert_eq!(j(&a), "1 0 0 1");
490    }
491
492    #[test]
493    fn char_vector_is_a_plain_string() {
494        let a = Array::from_chars("hello".chars().collect());
495        assert_eq!(j(&a), "hello");
496    }
497
498    // Matrices.
499
500    #[test]
501    fn matrix_columns_align_right() {
502        let a = Array::new(vec![2, 3], Data::I64(vec![1, 22, 333, 4444, 5, 66].into()));
503        assert_eq!(j(&a), "   1 22 333\n4444  5  66");
504    }
505
506    #[test]
507    fn matrix_negatives_widen_their_column() {
508        let a = Array::new(vec![2, 2], Data::I64(vec![-1, 10, 100, -2].into()));
509        assert_eq!(j(&a), " _1 10\n100 _2");
510        // `¯` is one column wide even though it is two bytes.
511        assert_eq!(apl(&a), " ¯1 10\n100 ¯2");
512    }
513
514    #[test]
515    fn matrix_of_floats() {
516        let a = Array::new(vec![2, 2], Data::F64(vec![0.5, 2.0, -1.0 / 3.0, 10.0].into()));
517        assert_eq!(j(&a), "      0.5  2\n_0.333333 10");
518    }
519
520    #[test]
521    fn matrix_of_bools() {
522        let a = Array::new(vec![2, 3], Data::Bool(vec![1, 0, 1, 0, 1, 0].into()));
523        assert_eq!(j(&a), "1 0 1\n0 1 0");
524    }
525
526    #[test]
527    fn single_column_matrix() {
528        let a = Array::new(vec![3, 1], Data::I64(vec![1, -20, 300].into()));
529        assert_eq!(j(&a), "  1\n_20\n300");
530    }
531
532    // Higher rank.
533
534    #[test]
535    fn rank_3_separates_planes_with_one_blank_line() {
536        let a = Array::new(vec![2, 2, 2], Data::I64(vec![1, 2, 3, 4, 5, 6, 7, 8].into()));
537        assert_eq!(j(&a), "1 2\n3 4\n\n5 6\n7 8");
538    }
539
540    #[test]
541    fn rank_3_column_widths_are_global() {
542        let a = Array::new(vec![2, 1, 2], Data::I64(vec![1, 2, 300, 4].into()));
543        assert_eq!(j(&a), "  1 2\n\n300 4");
544    }
545
546    #[test]
547    fn rank_4_separates_groups_with_two_blank_lines() {
548        let a = Array::new(vec![2, 2, 1, 2], Data::I64(vec![1, 2, 3, 4, 5, 6, 7, 8].into()));
549        assert_eq!(j(&a), "1 2\n\n3 4\n\n\n5 6\n\n7 8");
550    }
551
552    #[test]
553    fn rank_5_gap_grows_with_the_axis() {
554        let a = Array::new(vec![2, 1, 1, 1, 1], Data::I64(vec![1, 2].into()));
555        // The step is along axis -5: three blank lines.
556        assert_eq!(j(&a), "1\n\n\n\n2");
557    }
558
559    #[rstest]
560    // Frame [2], rank 3: every step is along axis -3.
561    #[case(&[2], 1, 1)]
562    // Frame [2, 3], rank 4: within a group one blank, across groups two.
563    #[case(&[2, 3], 1, 1)]
564    #[case(&[2, 3], 2, 1)]
565    #[case(&[2, 3], 3, 2)]
566    #[case(&[2, 3], 4, 1)]
567    fn plane_gaps(#[case] frame: &[usize], #[case] p: usize, #[case] want: usize) {
568        assert_eq!(plane_gap(frame, p), want);
569    }
570
571    // Characters at rank 2 and above.
572
573    #[test]
574    fn char_matrix_is_lines() {
575        let a = Array::new(vec![2, 3], Data::Char("abcdef".chars().collect()));
576        assert_eq!(j(&a), "abc\ndef");
577    }
578
579    #[test]
580    fn char_matrix_keeps_spaces_unpadded() {
581        let a = Array::new(vec![2, 3], Data::Char("a  bcd".chars().collect()));
582        assert_eq!(j(&a), "a  \nbcd");
583    }
584
585    #[test]
586    fn char_rank_3_separates_planes() {
587        let a = Array::new(vec![2, 2, 2], Data::Char("abcdefgh".chars().collect()));
588        assert_eq!(j(&a), "ab\ncd\n\nef\ngh");
589    }
590
591    // Boxes.
592
593    fn boxed(shape: &[usize], items: Vec<Array>) -> Array {
594        Array::new(shape.to_vec(), Data::Box(items.into()))
595    }
596
597    #[test]
598    fn a_box_is_drawn_as_a_fenced_cell() {
599        let a = boxed(&[], vec![Array::from_i64(vec![1, 2])]);
600        assert_eq!(j(&a), "+---+\n|1 2|\n+---+");
601        // APL spaces the contents instead of fencing them.
602        assert_eq!(apl(&a), " 1 2 ");
603    }
604
605    #[test]
606    fn a_boxed_vector_is_a_row_of_cells() {
607        let a = boxed(
608            &[3],
609            vec![
610                Array::scalar_i64(1),
611                Array::from_i64(vec![2, 3]),
612                Array::from_chars("abc".chars().collect()),
613            ],
614        );
615        assert_eq!(j(&a), "+-+---+---+\n|1|2 3|abc|\n+-+---+---+");
616        assert_eq!(apl(&a), " 1 2 3 abc ");
617    }
618
619    #[test]
620    fn a_tall_cell_pads_the_others_below_it() {
621        let a = boxed(
622            &[2],
623            vec![
624                Array::scalar_i64(1),
625                Array::new(vec![2, 2], Data::I64(vec![1, 2, 3, 4].into())),
626            ],
627        );
628        assert_eq!(j(&a), "+-+---+\n|1|1 2|\n| |3 4|\n+-+---+");
629    }
630
631    #[test]
632    fn a_nested_box_draws_inside_its_cell() {
633        let inner = boxed(&[], vec![Array::scalar_i64(5)]);
634        assert_eq!(j(&boxed(&[], vec![inner])), "+---+\n|+-+|\n||5||\n|+-+|\n+---+");
635    }
636
637    #[test]
638    fn a_box_matrix_fences_every_row() {
639        let a = boxed(&[2, 2], (1..=4).map(Array::scalar_i64).collect());
640        assert_eq!(j(&a), "+-+-+\n|1|2|\n+-+-+\n|3|4|\n+-+-+");
641        // Every element is a simple scalar, which APL reads as a mixed
642        // SIMPLE array: it draws like a plain one.
643        assert_eq!(apl(&a), "1 2\n3 4");
644    }
645
646    #[test]
647    fn a_boxed_empty_is_a_cell_of_width_zero() {
648        let a = boxed(&[], vec![Array::empty(DType::I64)]);
649        assert_eq!(j(&a), "++\n||\n++");
650        // A boxed array with an empty axis shows nothing at all.
651        assert_eq!(j(&Array::new(vec![0], Data::Box(Buf::new()))), "");
652    }
653
654    // Empties.
655
656    #[rstest]
657    #[case(DType::Bool)]
658    #[case(DType::I64)]
659    #[case(DType::F64)]
660    #[case(DType::Char)]
661    fn empty_vectors_print_nothing(#[case] dtype: DType) {
662        assert_eq!(j(&Array::empty(dtype)), "");
663    }
664
665    #[rstest]
666    #[case(&[0, 3])]
667    #[case(&[3, 0])]
668    #[case(&[2, 0, 4])]
669    fn any_empty_axis_prints_nothing(#[case] shape: &[usize]) {
670        let a = Array::new(shape.to_vec(), Data::I64(vec![].into()));
671        assert_eq!(j(&a), "");
672    }
673
674    #[test]
675    fn no_trailing_newline_or_spaces() {
676        let a = Array::new(vec![2, 2, 2], Data::I64(vec![1, 22, 3, 4, 5, 6, 7, 8].into()));
677        let s = j(&a);
678        assert!(!s.ends_with('\n'));
679        for line in s.lines() {
680            assert_eq!(line.trim_end(), line, "line has trailing space: {line:?}");
681        }
682    }
683}