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, spaced by `nested_gap` at rank 1
13    /// (matching GNU APL) or uniformly by one space at rank 2 and above;
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    // A sparse array shows what it stores, not what it stands for.
40    if let Some(s) = a.sparse_parts() {
41        return format_sparse(a, s, opts);
42    }
43    // An array with an empty axis has nothing to show.
44    if a.shape.contains(&0) {
45        return String::new();
46    }
47    // The planes are laid out by reading the buffer in order, so a
48    // column-major one is materialised first. Printing already costs more
49    // than the copy does.
50    if !a.is_row_major() {
51        return format_array(&a.to_row_major(), opts);
52    }
53    if a.dtype() == DType::Box {
54        // A boxed array whose every element is a simple scalar (peeling
55        // through any number of `⊂` layers) is APL's MIXED SIMPLE array:
56        // depth 1, and drawn the way a plain array is rather than with a
57        // nested display's extra spacing.
58        match mixed_simple_texts(a, opts) {
59            Some(texts) if opts.boxes == BoxStyle::Spaced && a.rank() == 1 => {
60                return mixed_vector_line(a, &texts)
61            }
62            Some(texts) if opts.boxes == BoxStyle::Spaced => {
63                return laid_out(&a.shape, texts, Cells::Right)
64            }
65            // A mixed VECTOR with at least one non-scalar item: GNU's
66            // nested display, not the fenced/uniformly-spaced box drawing.
67            None if opts.boxes == BoxStyle::Spaced && a.rank() == 1 => {
68                return nested_vector_line(a, opts)
69            }
70            _ => return format_boxed(a, opts),
71        }
72    }
73    let texts: Vec<String> = (0..a.count()).map(|i| format_atom(&a.data, i, opts)).collect();
74    laid_out(&a.shape, texts, Cells::of(a.dtype()))
75}
76
77/// A sparse array: one line per stored entry, that entry's position along
78/// the sparse axes, then `|`, then the cell it holds. Positions and values
79/// each align in their own column, over the whole display. An array with
80/// nothing stored — every position the sparse element — shows nothing, as
81/// an empty dense one does.
82fn format_sparse(a: &Array, s: &crate::sparse::Sparse, opts: &FmtOpts) -> String {
83    if s.entries == 0 {
84        return String::new();
85    }
86    let k = s.axes.len();
87    let width = s.cell_size(&a.shape);
88    let index: Vec<String> = s.indices.iter().map(|&i| format_i64(i as i64, opts)).collect();
89    let value: Vec<String> =
90        (0..s.entries * width).map(|i| format_atom(&a.data, i, opts)).collect();
91    let index_widths = column_widths(&index, k.max(1));
92    let value_widths = column_widths(&value, width.max(1));
93    let mut out = String::new();
94    for e in 0..s.entries {
95        if e > 0 {
96            out.push('\n');
97        }
98        push_row(&mut out, &index[e * k..(e + 1) * k], &index_widths, Cells::Right);
99        out.push_str(" | ");
100        push_row(&mut out, &value[e * width..(e + 1) * width], &value_widths, Cells::Right);
101    }
102    out
103}
104
105/// How the formatted elements of one row sit next to each other.
106#[derive(Clone, Copy, PartialEq)]
107enum Cells {
108    /// Numbers: one space between columns, each column right-aligned.
109    Right,
110    /// Characters: no separator at all, because the row IS the text.
111    Text,
112    /// Symbols: one space between columns, each column left-aligned and
113    /// padded on the right, which is how J prints a table of names.
114    Left,
115}
116
117impl Cells {
118    fn of(dtype: DType) -> Cells {
119        match dtype {
120            DType::Char => Cells::Text,
121            DType::Symbol => Cells::Left,
122            _ => Cells::Right,
123        }
124    }
125}
126
127/// Peel through scalar box wrappings (`⊂x`, `⊂⊂x`, and so on) to the value
128/// they finally hold, and how many layers were peeled. A box holding
129/// something that is itself non-scalar, or a value that was never a box,
130/// is its own leaf with zero layers.
131fn peel(a: &Array) -> (usize, &Array) {
132    let mut n = 0;
133    let mut cur = a;
134    while cur.rank() == 0 && cur.dtype() == DType::Box {
135        cur = &cur.as_boxes().expect("boxed scalar")[0];
136        n += 1;
137    }
138    (n, cur)
139}
140
141/// The scalar each element of a boxed array holds, where every one of them
142/// peels down to a simple scalar and nothing else — `⊂⊂5` counts, since
143/// enclosing a scalar again and again never makes it non-scalar.
144fn mixed_simple_texts(a: &Array, opts: &FmtOpts) -> Option<Vec<String>> {
145    let boxes = a.as_boxes()?;
146    let mut texts = Vec::with_capacity(boxes.len());
147    for b in boxes {
148        let (_, leaf) = peel(b);
149        if leaf.rank() != 0 {
150            return None;
151        }
152        texts.push(format_atom(&leaf.data, 0, opts));
153    }
154    Some(texts)
155}
156
157/// A mixed simple VECTOR on one line. A run of characters beside each
158/// other is text and runs together; every other join takes one space, so
159/// `1 2,'ab'` shows as `1 2 ab`. Higher ranks align in columns instead,
160/// and there each character is a column of its own.
161fn mixed_vector_line(a: &Array, texts: &[String]) -> String {
162    let letters: Vec<bool> = match a.as_boxes() {
163        Some(items) => items.iter().map(|e| peel(e).1.dtype() == DType::Char).collect(),
164        None => vec![false; texts.len()],
165    };
166    let mut out = String::new();
167    for (i, t) in texts.iter().enumerate() {
168        if i > 0 && !(letters[i] && letters[i - 1]) {
169            out.push(' ');
170        }
171        out.push_str(t);
172    }
173    out
174}
175
176/// How much a leaf widens the gap next to it in a nested display: nothing
177/// for a scalar, one column per axis for a numeric or boxed structure, and
178/// one column fewer for a character array, because a row of characters
179/// already reads as text on its own — a character VECTOR costs nothing
180/// extra (same as a scalar), a character MATRIX costs one column.
181fn gap_extra(leaf: &Array) -> usize {
182    match leaf.rank() {
183        0 => 0,
184        r if leaf.dtype() == DType::Char => r - 1,
185        r => r,
186    }
187}
188
189/// The gap between two adjacent items of a nested vector: no separator at
190/// all when both are lone characters (a run of text), otherwise one space
191/// plus however much the more complex neighbour's own shape asks for.
192fn nested_gap(left: &Array, right: &Array) -> usize {
193    let text_run = left.rank() == 0
194        && right.rank() == 0
195        && left.dtype() == DType::Char
196        && right.dtype() == DType::Char;
197    if text_run { 0 } else { 1 + gap_extra(left).max(gap_extra(right)) }
198}
199
200/// A boxed VECTOR with at least one non-scalar item: GNU's general nested
201/// display. Each item's OWN content draws plain, with no box wrapping of
202/// its own — every space around it comes from here instead. The gap
203/// between two items is `nested_gap`; the vector's own margin, front and
204/// back, is set by how many `⊂` layers wrap its first and its last item
205/// respectively (never fewer than one).
206fn nested_vector_line(a: &Array, opts: &FmtOpts) -> String {
207    let items = a.as_boxes().expect("boxed vector");
208    let peeled: Vec<(usize, &Array)> = items.iter().map(peel).collect();
209    let cells: Vec<(Vec<String>, usize)> = peeled.iter().map(|(_, leaf)| block(leaf, opts)).collect();
210    let height = cells.iter().map(|(lines, _)| lines.len()).max().unwrap_or(1);
211    let lead = " ".repeat(peeled.first().map_or(1, |(n, _)| (*n).max(1)));
212    let trail = " ".repeat(peeled.last().map_or(1, |(n, _)| (*n).max(1)));
213    let mut rows = vec![String::new(); height];
214    for i in 0..peeled.len() {
215        if i > 0 {
216            let sep = " ".repeat(nested_gap(peeled[i - 1].1, peeled[i].1));
217            for row in &mut rows {
218                row.push_str(&sep);
219            }
220        }
221        let (lines, w) = &cells[i];
222        for (r, row) in rows.iter_mut().enumerate() {
223            let text = lines.get(r).map(String::as_str).unwrap_or("");
224            row.push_str(text);
225            for _ in 0..w.saturating_sub(width(text)) {
226                row.push(' ');
227            }
228        }
229    }
230    rows.iter().map(|r| format!("{lead}{r}{trail}")).collect::<Vec<_>>().join("\n")
231}
232
233/// One formatted element per position, laid out for the shape: a vector on
234/// one line, higher ranks as aligned columns and planes.
235fn laid_out(shape: &[usize], texts: Vec<String>, cells: Cells) -> String {
236    let rank = shape.len();
237    let a_shape = shape;
238    match rank {
239        0 => texts.into_iter().next().unwrap_or_default(),
240        1 if cells == Cells::Text => texts.concat(),
241        1 => texts.join(" "),
242        _ => {
243            let ncols = a_shape[rank - 1];
244            let nrows = a_shape[rank - 2];
245            // Column widths span every plane, so planes stay aligned with
246            // each other and not just internally.
247            let widths = if cells == Cells::Text {
248                vec![0; ncols]
249            } else {
250                column_widths(&texts, ncols)
251            };
252            let frame = &a_shape[..rank - 2];
253            let plane_size = nrows * ncols;
254            let planes: usize = frame.iter().product();
255            let mut out = String::new();
256            for p in 0..planes {
257                if p > 0 {
258                    // One newline ends the previous line, the rest are blanks.
259                    out.push_str(&"\n".repeat(plane_gap(frame, p) + 1));
260                }
261                for r in 0..nrows {
262                    if r > 0 {
263                        out.push('\n');
264                    }
265                    let start = p * plane_size + r * ncols;
266                    push_row(&mut out, &texts[start..start + ncols], &widths, cells);
267                }
268            }
269            out
270        }
271    }
272}
273
274/// A boxed array as its language draws it: the last two axes form a table
275/// of cells, each holding its own contents' display, and the axes above
276/// them separate planes exactly as they do for numbers.
277fn format_boxed(a: &Array, opts: &FmtOpts) -> String {
278    let boxes = a.as_boxes().expect("boxed data");
279    let blocks: Vec<(Vec<String>, usize)> = boxes.iter().map(|b| block(b, opts)).collect();
280    let rank = a.rank();
281    let (nrows, ncols) = match rank {
282        0 => (1, 1),
283        1 => (1, a.shape[0]),
284        _ => (a.shape[rank - 2], a.shape[rank - 1]),
285    };
286    // Column widths span the whole array, as they do for numeric columns.
287    let mut widths = vec![0usize; ncols];
288    for (i, (_, w)) in blocks.iter().enumerate() {
289        widths[i % ncols] = widths[i % ncols].max(*w);
290    }
291    let frame: &[usize] = if rank > 2 { &a.shape[..rank - 2] } else { &[] };
292    let planes: usize = frame.iter().product();
293    let plane_size = nrows * ncols;
294    let mut out = String::new();
295    for p in 0..planes.max(1) {
296        if p > 0 {
297            out.push_str(&"\n".repeat(plane_gap(frame, p) + 1));
298        }
299        push_boxed_plane(
300            &mut out,
301            &blocks[p * plane_size..(p + 1) * plane_size],
302            nrows,
303            ncols,
304            &widths,
305            opts,
306        );
307    }
308    out
309}
310
311/// One box's contents as display lines, and the width they need.
312///
313/// An empty array has no text at all, and its SHAPE decides the cell:
314/// every axis but the last counts a row, and the last one is how wide the
315/// cell draws. So `<''` is one empty line inside a zero-wide cell, `<0 3$0`
316/// is a cell three wide with no lines in it, and `<2 0$0` is two empty
317/// lines. The width has to travel beside the lines because a cell with no
318/// lines still has one.
319fn block(a: &Array, opts: &FmtOpts) -> (Vec<String>, usize) {
320    if a.count() == 0 && a.rank() > 0 {
321        let rank = a.rank();
322        let rows: usize = a.shape[..rank - 1].iter().product();
323        let w = a.shape[rank - 1];
324        return (vec![" ".repeat(w); rows], w);
325    }
326    let text = format_array(a, opts);
327    if text.is_empty() {
328        return (vec![String::new()], 0);
329    }
330    let lines: Vec<String> = text.lines().map(str::to_string).collect();
331    let w = lines.iter().map(|l| width(l)).max().unwrap_or(0);
332    (lines, w)
333}
334
335fn push_boxed_plane(
336    out: &mut String,
337    blocks: &[(Vec<String>, usize)],
338    nrows: usize,
339    ncols: usize,
340    widths: &[usize],
341    opts: &FmtOpts,
342) {
343    let fence = opts.boxes == BoxStyle::Fenced;
344    let border: String = if fence {
345        let mut s = String::from("+");
346        for &w in widths {
347            s.push_str(&"-".repeat(w));
348            s.push('+');
349        }
350        s
351    } else {
352        String::new()
353    };
354    let mut lines: Vec<String> = Vec::new();
355    for r in 0..nrows {
356        if fence {
357            lines.push(border.clone());
358        }
359        let row = &blocks[r * ncols..(r + 1) * ncols];
360        // A row is as tall as its tallest cell; the others are padded
361        // underneath, which is where J puts the blanks.
362        let height = row.iter().map(|(lines, _)| lines.len()).max().unwrap_or(1);
363        for k in 0..height {
364            let mut line = String::new();
365            line.push(if fence { '|' } else { ' ' });
366            for (c, (cell, _)) in row.iter().enumerate() {
367                if !fence && c > 0 {
368                    line.push(' ');
369                }
370                let text = cell.get(k).map(String::as_str).unwrap_or("");
371                line.push_str(text);
372                for _ in 0..widths[c].saturating_sub(width(text)) {
373                    line.push(' ');
374                }
375                if fence {
376                    line.push('|');
377                }
378            }
379            if !fence {
380                line.push(' ');
381            }
382            lines.push(line);
383        }
384    }
385    if fence {
386        lines.push(border);
387    }
388    out.push_str(&lines.join("\n"));
389}
390
391/// Blank lines before plane `p`: one for a step along axis -3, two along
392/// axis -4, and so on. `frame` is the shape without its last two axes.
393fn plane_gap(frame: &[usize], p: usize) -> usize {
394    // The step size is one plus the number of trailing odometer digits of
395    // `p` that have just rolled over to zero.
396    let mut gap = 1;
397    let mut rest = p;
398    for &n in frame.iter().rev() {
399        if rest % n != 0 {
400            break;
401        }
402        rest /= n;
403        gap += 1;
404    }
405    gap
406}
407
408/// Widest formatted element per column index, taken over the whole array.
409fn column_widths(texts: &[String], ncols: usize) -> Vec<usize> {
410    let mut widths = vec![0usize; ncols];
411    for (i, t) in texts.iter().enumerate() {
412        let j = i % ncols;
413        widths[j] = widths[j].max(width(t));
414    }
415    widths
416}
417
418fn push_row(out: &mut String, row: &[String], widths: &[usize], cells: Cells) {
419    for (j, cell) in row.iter().enumerate() {
420        if cells == Cells::Text {
421            out.push_str(cell);
422            continue;
423        }
424        if j > 0 {
425            out.push(' ');
426        }
427        let pad = widths[j].saturating_sub(width(cell));
428        if cells == Cells::Right {
429            for _ in 0..pad {
430                out.push(' ');
431            }
432        }
433        out.push_str(cell);
434        if cells == Cells::Left {
435            for _ in 0..pad {
436                out.push(' ');
437            }
438        }
439    }
440}
441
442/// Display width in characters; the APL minus sign is multi-byte.
443fn width(s: &str) -> usize {
444    s.chars().count()
445}
446
447fn format_atom(data: &Data, i: usize, opts: &FmtOpts) -> String {
448    match data {
449        Data::Bool(v) => (if v[i] != 0 { "1" } else { "0" }).to_string(),
450        Data::I64(v) => format_i64(v[i], opts),
451        Data::Ext(v) => with_neg_sign(&v[i].to_string(), opts),
452        Data::Rat(v) => with_neg_sign(&v[i].to_string(), opts),
453        Data::F64(v) => format_f64(v[i], opts),
454        Data::Complex(v) => format_complex(v[i], opts),
455        Data::Char(v) => v[i].to_string(),
456        // A symbol prints as its name behind the backtick that makes one.
457        Data::Symbol(v) => format!("`{}", crate::symbol::name(v[i])),
458        // Boxed data takes the drawing path before reaching here.
459        Data::Box(_) => String::new(),
460    }
461}
462
463/// A complex number, as both references print one: the two parts joined by
464/// `j`/`J`, and the real part alone when the imaginary part is exactly zero.
465/// The demotion is in the display only — the value keeps its complex type,
466/// which is what `3!:0` reports of it in J.
467fn format_complex(z: crate::complex::Cx, opts: &FmtOpts) -> String {
468    if z[1] == 0.0 {
469        return format_f64(z[0], opts);
470    }
471    format!("{}{}{}", format_f64(z[0], opts), opts.imag, format_f64(z[1], opts))
472}
473
474fn format_i64(v: i64, opts: &FmtOpts) -> String {
475    with_neg_sign(&v.to_string(), opts)
476}
477
478/// A Rust-formatted number with its leading `-` replaced by the language’s
479/// own negative sign. An extended integer and a rational both arrive here
480/// already spelled the way J spells them (`123`, `_1r2` once the sign is
481/// swapped), so nothing else has to be rewritten.
482fn with_neg_sign(s: &str, opts: &FmtOpts) -> String {
483    match s.strip_prefix('-') {
484        Some(rest) => with_sign(rest, opts),
485        None => s.to_string(),
486    }
487}
488
489fn format_f64(x: f64, opts: &FmtOpts) -> String {
490    if x.is_nan() {
491        return format!("{}.", opts.neg);
492    }
493    if x.is_infinite() {
494        // J spells the infinities `_` and `__`; APL has no standard glyph.
495        return match (opts.neg, x > 0.0) {
496            ('_', true) => "_".to_string(),
497            ('_', false) => "__".to_string(),
498            (_, true) => "∞".to_string(),
499            (neg, false) => format!("{neg}∞"),
500        };
501    }
502    let magnitude = x.abs();
503    // Round to `SIG_DIGITS` first, then decide how to spell the result;
504    // scientific formatting hands us the digits and the exponent directly.
505    let sci = format!("{:.*e}", SIG_DIGITS - 1, magnitude);
506    let (mantissa, exponent) = sci.split_once('e').expect("scientific form has an exponent");
507    let digits: String = mantissa.chars().filter(char::is_ascii_digit).collect();
508    let exponent: i32 = exponent.parse().expect("exponent is an integer");
509    let body = if exponent >= 12 || exponent <= -6 {
510        let mut s = trim_fraction(&place_point(&digits, 1));
511        s.push('e');
512        if exponent < 0 {
513            s.push_str(&with_sign(&(-(exponent as i64)).to_string(), opts));
514        } else {
515            s.push_str(&exponent.to_string());
516        }
517        s
518    } else {
519        positional(&digits, exponent)
520    };
521    if x < 0.0 { with_sign(&body, opts) } else { body }
522}
523
524/// `digits` written out with the decimal point implied by `exponent`.
525fn positional(digits: &str, exponent: i32) -> String {
526    if exponent < 0 {
527        let zeros = (-exponent - 1) as usize;
528        return trim_fraction(&format!("0.{}{}", "0".repeat(zeros), digits));
529    }
530    let int_len = exponent as usize + 1;
531    if int_len >= digits.len() {
532        // Rounding put the last significant digit left of the point; the
533        // padding zeros carry magnitude, so there is nothing to trim.
534        return format!("{}{}", digits, "0".repeat(int_len - digits.len()));
535    }
536    trim_fraction(&place_point(digits, int_len))
537}
538
539/// Insert a decimal point after `int_len` digits.
540fn place_point(digits: &str, int_len: usize) -> String {
541    format!("{}.{}", &digits[..int_len], &digits[int_len..])
542}
543
544/// Drop trailing fraction zeros, then a bare trailing point.
545fn trim_fraction(s: &str) -> String {
546    if !s.contains('.') {
547        return s.to_string();
548    }
549    s.trim_end_matches('0').trim_end_matches('.').to_string()
550}
551
552fn with_sign(body: &str, opts: &FmtOpts) -> String {
553    let mut s = String::with_capacity(body.len() + opts.neg.len_utf8());
554    s.push(opts.neg);
555    s.push_str(body);
556    s
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562    use crate::array::Buf;
563    use rstest::rstest;
564
565    fn j(a: &Array) -> String {
566        format_array(a, &FmtOpts::J)
567    }
568
569    fn apl(a: &Array) -> String {
570        format_array(a, &FmtOpts::APL)
571    }
572
573    fn fj(x: f64) -> String {
574        format_f64(x, &FmtOpts::J)
575    }
576
577    // Atoms.
578
579    #[rstest]
580    #[case(0, "0")]
581    #[case(7, "7")]
582    #[case(-3, "_3")]
583    #[case(-1234, "_1234")]
584    #[case(i64::MIN, "_9223372036854775808")]
585    fn integers_j(#[case] v: i64, #[case] want: &str) {
586        assert_eq!(format_i64(v, &FmtOpts::J), want);
587    }
588
589    #[rstest]
590    #[case(-3, "¯3")]
591    #[case(3, "3")]
592    fn integers_apl(#[case] v: i64, #[case] want: &str) {
593        assert_eq!(format_i64(v, &FmtOpts::APL), want);
594    }
595
596    #[rstest]
597    #[case(0.0, "0")]
598    #[case(0.5, "0.5")]
599    #[case(2.0, "2")]
600    #[case(-2.0, "_2")]
601    #[case(1.0 / 3.0, "0.333333")]
602    #[case(-1.0 / 3.0, "_0.333333")]
603    #[case(2.0 / 3.0, "0.666667")]
604    #[case(1.25, "1.25")]
605    #[case(100.0, "100")]
606    #[case(1e-5, "0.00001")]
607    #[case(0.000012345678, "0.0000123457")]
608    #[case(1e11, "100000000000")]
609    #[case(123456789.0, "123457000")]
610    fn floats_positional(#[case] x: f64, #[case] want: &str) {
611        assert_eq!(fj(x), want);
612    }
613
614    #[rstest]
615    #[case(1e-7, "1e_7")]
616    #[case(-1e-7, "_1e_7")]
617    #[case(1.5e13, "1.5e13")]
618    #[case(1e12, "1e12")]
619    #[case(-2.5e20, "_2.5e20")]
620    #[case(1.234567e-9, "1.23457e_9")]
621    fn floats_exponent(#[case] x: f64, #[case] want: &str) {
622        assert_eq!(fj(x), want);
623    }
624
625    #[test]
626    fn floats_apl_signs() {
627        assert_eq!(format_f64(-0.5, &FmtOpts::APL), "¯0.5");
628        assert_eq!(format_f64(1e-7, &FmtOpts::APL), "1e¯7");
629        assert_eq!(format_f64(-1e-7, &FmtOpts::APL), "¯1e¯7");
630    }
631
632    #[test]
633    fn negative_zero_prints_unsigned() {
634        assert_eq!(fj(-0.0), "0");
635    }
636
637    #[test]
638    fn nan_and_infinities() {
639        assert_eq!(fj(f64::NAN), "_.");
640        assert_eq!(fj(f64::INFINITY), "_");
641        assert_eq!(fj(f64::NEG_INFINITY), "__");
642        assert_eq!(format_f64(f64::NAN, &FmtOpts::APL), "¯.");
643        assert_eq!(format_f64(f64::INFINITY, &FmtOpts::APL), "∞");
644        assert_eq!(format_f64(f64::NEG_INFINITY, &FmtOpts::APL), "¯∞");
645    }
646
647    #[test]
648    fn scalars() {
649        assert_eq!(j(&Array::scalar_i64(-3)), "_3");
650        assert_eq!(apl(&Array::scalar_i64(-3)), "¯3");
651        assert_eq!(j(&Array::scalar_f64(0.5)), "0.5");
652        assert_eq!(j(&Array::scalar_bool(true)), "1");
653        assert_eq!(j(&Array::scalar_bool(false)), "0");
654        assert_eq!(j(&Array::new(vec![], Data::Char(vec!['q'].into()))), "q");
655    }
656
657    // Vectors.
658
659    #[test]
660    fn integer_vector() {
661        let a = Array::from_i64(vec![1, -22, 333]);
662        assert_eq!(j(&a), "1 _22 333");
663        assert_eq!(apl(&a), "1 ¯22 333");
664    }
665
666    #[test]
667    fn float_vector_trims_independently() {
668        let a = Array::from_f64(vec![0.5, 2.0, 1.0 / 3.0, -1e-7]);
669        assert_eq!(j(&a), "0.5 2 0.333333 _1e_7");
670    }
671
672    #[test]
673    fn bool_vector() {
674        let a = Array::new(vec![4], Data::Bool(vec![1, 0, 0, 1].into()));
675        assert_eq!(j(&a), "1 0 0 1");
676    }
677
678    #[test]
679    fn char_vector_is_a_plain_string() {
680        let a = Array::from_chars("hello".chars().collect());
681        assert_eq!(j(&a), "hello");
682    }
683
684    // Matrices.
685
686    #[test]
687    fn matrix_columns_align_right() {
688        let a = Array::new(vec![2, 3], Data::I64(vec![1, 22, 333, 4444, 5, 66].into()));
689        assert_eq!(j(&a), "   1 22 333\n4444  5  66");
690    }
691
692    #[test]
693    fn matrix_negatives_widen_their_column() {
694        let a = Array::new(vec![2, 2], Data::I64(vec![-1, 10, 100, -2].into()));
695        assert_eq!(j(&a), " _1 10\n100 _2");
696        // `¯` is one column wide even though it is two bytes.
697        assert_eq!(apl(&a), " ¯1 10\n100 ¯2");
698    }
699
700    #[test]
701    fn matrix_of_floats() {
702        let a = Array::new(vec![2, 2], Data::F64(vec![0.5, 2.0, -1.0 / 3.0, 10.0].into()));
703        assert_eq!(j(&a), "      0.5  2\n_0.333333 10");
704    }
705
706    #[test]
707    fn matrix_of_bools() {
708        let a = Array::new(vec![2, 3], Data::Bool(vec![1, 0, 1, 0, 1, 0].into()));
709        assert_eq!(j(&a), "1 0 1\n0 1 0");
710    }
711
712    #[test]
713    fn single_column_matrix() {
714        let a = Array::new(vec![3, 1], Data::I64(vec![1, -20, 300].into()));
715        assert_eq!(j(&a), "  1\n_20\n300");
716    }
717
718    // Higher rank.
719
720    #[test]
721    fn rank_3_separates_planes_with_one_blank_line() {
722        let a = Array::new(vec![2, 2, 2], Data::I64(vec![1, 2, 3, 4, 5, 6, 7, 8].into()));
723        assert_eq!(j(&a), "1 2\n3 4\n\n5 6\n7 8");
724    }
725
726    #[test]
727    fn rank_3_column_widths_are_global() {
728        let a = Array::new(vec![2, 1, 2], Data::I64(vec![1, 2, 300, 4].into()));
729        assert_eq!(j(&a), "  1 2\n\n300 4");
730    }
731
732    #[test]
733    fn rank_4_separates_groups_with_two_blank_lines() {
734        let a = Array::new(vec![2, 2, 1, 2], Data::I64(vec![1, 2, 3, 4, 5, 6, 7, 8].into()));
735        assert_eq!(j(&a), "1 2\n\n3 4\n\n\n5 6\n\n7 8");
736    }
737
738    #[test]
739    fn rank_5_gap_grows_with_the_axis() {
740        let a = Array::new(vec![2, 1, 1, 1, 1], Data::I64(vec![1, 2].into()));
741        // The step is along axis -5: three blank lines.
742        assert_eq!(j(&a), "1\n\n\n\n2");
743    }
744
745    #[rstest]
746    // Frame [2], rank 3: every step is along axis -3.
747    #[case(&[2], 1, 1)]
748    // Frame [2, 3], rank 4: within a group one blank, across groups two.
749    #[case(&[2, 3], 1, 1)]
750    #[case(&[2, 3], 2, 1)]
751    #[case(&[2, 3], 3, 2)]
752    #[case(&[2, 3], 4, 1)]
753    fn plane_gaps(#[case] frame: &[usize], #[case] p: usize, #[case] want: usize) {
754        assert_eq!(plane_gap(frame, p), want);
755    }
756
757    // Characters at rank 2 and above.
758
759    #[test]
760    fn char_matrix_is_lines() {
761        let a = Array::new(vec![2, 3], Data::Char("abcdef".chars().collect()));
762        assert_eq!(j(&a), "abc\ndef");
763    }
764
765    #[test]
766    fn char_matrix_keeps_spaces_unpadded() {
767        let a = Array::new(vec![2, 3], Data::Char("a  bcd".chars().collect()));
768        assert_eq!(j(&a), "a  \nbcd");
769    }
770
771    #[test]
772    fn char_rank_3_separates_planes() {
773        let a = Array::new(vec![2, 2, 2], Data::Char("abcdefgh".chars().collect()));
774        assert_eq!(j(&a), "ab\ncd\n\nef\ngh");
775    }
776
777    // Boxes.
778
779    fn boxed(shape: &[usize], items: Vec<Array>) -> Array {
780        Array::new(shape.to_vec(), Data::Box(items.into()))
781    }
782
783    #[test]
784    fn a_box_is_drawn_as_a_fenced_cell() {
785        let a = boxed(&[], vec![Array::from_i64(vec![1, 2])]);
786        assert_eq!(j(&a), "+---+\n|1 2|\n+---+");
787        // APL spaces the contents instead of fencing them.
788        assert_eq!(apl(&a), " 1 2 ");
789    }
790
791    #[test]
792    fn a_boxed_vector_is_a_row_of_cells() {
793        let a = boxed(
794            &[3],
795            vec![
796                Array::scalar_i64(1),
797                Array::from_i64(vec![2, 3]),
798                Array::from_chars("abc".chars().collect()),
799            ],
800        );
801        assert_eq!(j(&a), "+-+---+---+\n|1|2 3|abc|\n+-+---+---+");
802        // A non-scalar item (the unenclosed vector `2 3`) widens the gap
803        // beside it by one column; the character vector costs nothing
804        // extra, since a run of characters already reads as text.
805        assert_eq!(apl(&a), " 1  2 3  abc ");
806    }
807
808    // The nested display rule (GNU-taught, corpus/apl/nested_display.txt
809    // carries the oracle-checked cases): a run of adjacent characters is
810    // text with no separator; elsewhere the gap widens by the more
811    // complex neighbour's own shape, and the outer margin is set by how
812    // many boxes wrap the first and the last item.
813
814    #[test]
815    fn a_char_run_merges_through_a_box() {
816        // `'a',⊂'b'` — a boxed character beside a plain one is still text,
817        // whatever `⊂` it is wrapped in.
818        let ch = |c: char| Array::new(vec![], Data::Char(vec![c].into()));
819        let a = boxed(&[2], vec![ch('a'), boxed(&[], vec![ch('b')])]);
820        assert_eq!(apl(&a), "ab");
821    }
822
823    #[test]
824    fn a_nonscalar_item_widens_its_own_gap() {
825        // `1,⊂1 2` — a boxed vector costs one extra column beside a scalar.
826        let a = boxed(&[2], vec![Array::scalar_i64(1), Array::from_i64(vec![1, 2])]);
827        assert_eq!(apl(&a), " 1  1 2 ");
828    }
829
830    #[test]
831    fn a_boxed_matrix_costs_two_columns() {
832        // `1,⊂2 2⍴1 2 3 4` — rank widens the gap further than a vector does.
833        let a = boxed(
834            &[2],
835            vec![Array::scalar_i64(1), Array::new(vec![2, 2], Data::I64(vec![1, 2, 3, 4].into()))],
836        );
837        assert_eq!(apl(&a), " 1   1 2 \n     3 4 ");
838    }
839
840    #[test]
841    fn a_character_vector_costs_one_column_less_than_its_rank() {
842        // `1,⊂'abc'` — text needs no extra column even though it is rank 1.
843        let a = boxed(&[2], vec![Array::scalar_i64(1), Array::from_chars("abc".chars().collect())]);
844        assert_eq!(apl(&a), " 1 abc ");
845    }
846
847    #[test]
848    fn the_outer_margin_follows_the_edge_items_own_box_depth() {
849        // `⊂⊂1 2,1` — a doubly-enclosed first item widens the lead margin.
850        let inner = boxed(&[], vec![Array::from_i64(vec![1, 2])]);
851        let a = boxed(&[2], vec![boxed(&[], vec![inner]), Array::scalar_i64(1)]);
852        assert_eq!(apl(&a), "  1 2  1 ");
853    }
854
855    #[test]
856    fn a_tall_cell_pads_the_others_below_it() {
857        let a = boxed(
858            &[2],
859            vec![
860                Array::scalar_i64(1),
861                Array::new(vec![2, 2], Data::I64(vec![1, 2, 3, 4].into())),
862            ],
863        );
864        assert_eq!(j(&a), "+-+---+\n|1|1 2|\n| |3 4|\n+-+---+");
865    }
866
867    #[test]
868    fn a_nested_box_draws_inside_its_cell() {
869        let inner = boxed(&[], vec![Array::scalar_i64(5)]);
870        assert_eq!(j(&boxed(&[], vec![inner])), "+---+\n|+-+|\n||5||\n|+-+|\n+---+");
871    }
872
873    #[test]
874    fn a_box_matrix_fences_every_row() {
875        let a = boxed(&[2, 2], (1..=4).map(Array::scalar_i64).collect());
876        assert_eq!(j(&a), "+-+-+\n|1|2|\n+-+-+\n|3|4|\n+-+-+");
877        // Every element is a simple scalar, which APL reads as a mixed
878        // SIMPLE array: it draws like a plain one.
879        assert_eq!(apl(&a), "1 2\n3 4");
880    }
881
882    #[test]
883    fn a_boxed_empty_is_a_cell_of_width_zero() {
884        let a = boxed(&[], vec![Array::empty(DType::I64)]);
885        assert_eq!(j(&a), "++\n||\n++");
886        // A boxed array with an empty axis shows nothing at all.
887        assert_eq!(j(&Array::new(vec![0], Data::Box(Buf::new()))), "");
888    }
889
890    // Empties.
891
892    #[rstest]
893    #[case(DType::Bool)]
894    #[case(DType::I64)]
895    #[case(DType::F64)]
896    #[case(DType::Char)]
897    fn empty_vectors_print_nothing(#[case] dtype: DType) {
898        assert_eq!(j(&Array::empty(dtype)), "");
899    }
900
901    #[rstest]
902    #[case(&[0, 3])]
903    #[case(&[3, 0])]
904    #[case(&[2, 0, 4])]
905    fn any_empty_axis_prints_nothing(#[case] shape: &[usize]) {
906        let a = Array::new(shape.to_vec(), Data::I64(vec![].into()));
907        assert_eq!(j(&a), "");
908    }
909
910    #[test]
911    fn no_trailing_newline_or_spaces() {
912        let a = Array::new(vec![2, 2, 2], Data::I64(vec![1, 22, 3, 4, 5, 6, 7, 8].into()));
913        let s = j(&a);
914        assert!(!s.ends_with('\n'));
915        for line in s.lines() {
916            assert_eq!(line.trim_end(), line, "line has trailing space: {line:?}");
917        }
918    }
919}