use crate::array::{Array, Data};
use crate::dtype::DType;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BoxStyle {
Fenced,
Spaced,
}
#[derive(Clone, Copy, Debug)]
pub struct FmtOpts {
pub neg: char,
pub imag: char,
pub boxes: BoxStyle,
}
impl FmtOpts {
pub const J: FmtOpts = FmtOpts { neg: '_', imag: 'j', boxes: BoxStyle::Fenced };
pub const APL: FmtOpts = FmtOpts { neg: '¯', imag: 'J', boxes: BoxStyle::Spaced };
}
const SIG_DIGITS: usize = 6;
pub fn format_array(a: &Array, opts: &FmtOpts) -> String {
if a.shape.contains(&0) {
return String::new();
}
if !a.is_row_major() {
return format_array(&a.to_row_major(), opts);
}
if a.dtype() == DType::Box {
match mixed_simple_texts(a, opts) {
Some(texts) if opts.boxes == BoxStyle::Spaced => {
return laid_out(&a.shape, texts, Cells::Right)
}
_ => return format_boxed(a, opts),
}
}
let texts: Vec<String> = (0..a.count()).map(|i| format_atom(&a.data, i, opts)).collect();
laid_out(&a.shape, texts, Cells::of(a.dtype()))
}
#[derive(Clone, Copy, PartialEq)]
enum Cells {
Right,
Text,
Left,
}
impl Cells {
fn of(dtype: DType) -> Cells {
match dtype {
DType::Char => Cells::Text,
DType::Symbol => Cells::Left,
_ => Cells::Right,
}
}
}
fn mixed_simple_texts(a: &Array, opts: &FmtOpts) -> Option<Vec<String>> {
let boxes = a.as_boxes()?;
let mut texts = Vec::with_capacity(boxes.len());
for b in boxes {
if b.rank() != 0 || b.dtype() == DType::Box {
return None;
}
texts.push(format_atom(&b.data, 0, opts));
}
Some(texts)
}
fn laid_out(shape: &[usize], texts: Vec<String>, cells: Cells) -> String {
let rank = shape.len();
let a_shape = shape;
match rank {
0 => texts.into_iter().next().unwrap_or_default(),
1 if cells == Cells::Text => texts.concat(),
1 => texts.join(" "),
_ => {
let ncols = a_shape[rank - 1];
let nrows = a_shape[rank - 2];
let widths = if cells == Cells::Text {
vec![0; ncols]
} else {
column_widths(&texts, ncols)
};
let frame = &a_shape[..rank - 2];
let plane_size = nrows * ncols;
let planes: usize = frame.iter().product();
let mut out = String::new();
for p in 0..planes {
if p > 0 {
out.push_str(&"\n".repeat(plane_gap(frame, p) + 1));
}
for r in 0..nrows {
if r > 0 {
out.push('\n');
}
let start = p * plane_size + r * ncols;
push_row(&mut out, &texts[start..start + ncols], &widths, cells);
}
}
out
}
}
}
fn format_boxed(a: &Array, opts: &FmtOpts) -> String {
let boxes = a.as_boxes().expect("boxed data");
let blocks: Vec<(Vec<String>, usize)> = boxes.iter().map(|b| block(b, opts)).collect();
let rank = a.rank();
let (nrows, ncols) = match rank {
0 => (1, 1),
1 => (1, a.shape[0]),
_ => (a.shape[rank - 2], a.shape[rank - 1]),
};
let mut widths = vec![0usize; ncols];
for (i, (_, w)) in blocks.iter().enumerate() {
widths[i % ncols] = widths[i % ncols].max(*w);
}
let frame: &[usize] = if rank > 2 { &a.shape[..rank - 2] } else { &[] };
let planes: usize = frame.iter().product();
let plane_size = nrows * ncols;
let mut out = String::new();
for p in 0..planes.max(1) {
if p > 0 {
out.push_str(&"\n".repeat(plane_gap(frame, p) + 1));
}
push_boxed_plane(
&mut out,
&blocks[p * plane_size..(p + 1) * plane_size],
nrows,
ncols,
&widths,
opts,
);
}
out
}
fn block(a: &Array, opts: &FmtOpts) -> (Vec<String>, usize) {
if a.count() == 0 && a.rank() > 0 {
let rank = a.rank();
let rows: usize = a.shape[..rank - 1].iter().product();
let w = a.shape[rank - 1];
return (vec![" ".repeat(w); rows], w);
}
let text = format_array(a, opts);
if text.is_empty() {
return (vec![String::new()], 0);
}
let lines: Vec<String> = text.lines().map(str::to_string).collect();
let w = lines.iter().map(|l| width(l)).max().unwrap_or(0);
(lines, w)
}
fn push_boxed_plane(
out: &mut String,
blocks: &[(Vec<String>, usize)],
nrows: usize,
ncols: usize,
widths: &[usize],
opts: &FmtOpts,
) {
let fence = opts.boxes == BoxStyle::Fenced;
let border: String = if fence {
let mut s = String::from("+");
for &w in widths {
s.push_str(&"-".repeat(w));
s.push('+');
}
s
} else {
String::new()
};
let mut lines: Vec<String> = Vec::new();
for r in 0..nrows {
if fence {
lines.push(border.clone());
}
let row = &blocks[r * ncols..(r + 1) * ncols];
let height = row.iter().map(|(lines, _)| lines.len()).max().unwrap_or(1);
for k in 0..height {
let mut line = String::new();
line.push(if fence { '|' } else { ' ' });
for (c, (cell, _)) in row.iter().enumerate() {
if !fence && c > 0 {
line.push(' ');
}
let text = cell.get(k).map(String::as_str).unwrap_or("");
line.push_str(text);
for _ in 0..widths[c].saturating_sub(width(text)) {
line.push(' ');
}
if fence {
line.push('|');
}
}
if !fence {
line.push(' ');
}
lines.push(line);
}
}
if fence {
lines.push(border);
}
out.push_str(&lines.join("\n"));
}
fn plane_gap(frame: &[usize], p: usize) -> usize {
let mut gap = 1;
let mut rest = p;
for &n in frame.iter().rev() {
if rest % n != 0 {
break;
}
rest /= n;
gap += 1;
}
gap
}
fn column_widths(texts: &[String], ncols: usize) -> Vec<usize> {
let mut widths = vec![0usize; ncols];
for (i, t) in texts.iter().enumerate() {
let j = i % ncols;
widths[j] = widths[j].max(width(t));
}
widths
}
fn push_row(out: &mut String, row: &[String], widths: &[usize], cells: Cells) {
for (j, cell) in row.iter().enumerate() {
if cells == Cells::Text {
out.push_str(cell);
continue;
}
if j > 0 {
out.push(' ');
}
let pad = widths[j].saturating_sub(width(cell));
if cells == Cells::Right {
for _ in 0..pad {
out.push(' ');
}
}
out.push_str(cell);
if cells == Cells::Left {
for _ in 0..pad {
out.push(' ');
}
}
}
}
fn width(s: &str) -> usize {
s.chars().count()
}
fn format_atom(data: &Data, i: usize, opts: &FmtOpts) -> String {
match data {
Data::Bool(v) => (if v[i] != 0 { "1" } else { "0" }).to_string(),
Data::I64(v) => format_i64(v[i], opts),
Data::Ext(v) => with_neg_sign(&v[i].to_string(), opts),
Data::Rat(v) => with_neg_sign(&v[i].to_string(), opts),
Data::F64(v) => format_f64(v[i], opts),
Data::Complex(v) => format_complex(v[i], opts),
Data::Char(v) => v[i].to_string(),
Data::Symbol(v) => format!("`{}", crate::symbol::name(v[i])),
Data::Box(_) => String::new(),
}
}
fn format_complex(z: crate::complex::Cx, opts: &FmtOpts) -> String {
if z[1] == 0.0 {
return format_f64(z[0], opts);
}
format!("{}{}{}", format_f64(z[0], opts), opts.imag, format_f64(z[1], opts))
}
fn format_i64(v: i64, opts: &FmtOpts) -> String {
with_neg_sign(&v.to_string(), opts)
}
fn with_neg_sign(s: &str, opts: &FmtOpts) -> String {
match s.strip_prefix('-') {
Some(rest) => with_sign(rest, opts),
None => s.to_string(),
}
}
fn format_f64(x: f64, opts: &FmtOpts) -> String {
if x.is_nan() {
return format!("{}.", opts.neg);
}
if x.is_infinite() {
return match (opts.neg, x > 0.0) {
('_', true) => "_".to_string(),
('_', false) => "__".to_string(),
(_, true) => "∞".to_string(),
(neg, false) => format!("{neg}∞"),
};
}
let magnitude = x.abs();
let sci = format!("{:.*e}", SIG_DIGITS - 1, magnitude);
let (mantissa, exponent) = sci.split_once('e').expect("scientific form has an exponent");
let digits: String = mantissa.chars().filter(char::is_ascii_digit).collect();
let exponent: i32 = exponent.parse().expect("exponent is an integer");
let body = if exponent >= 12 || exponent <= -6 {
let mut s = trim_fraction(&place_point(&digits, 1));
s.push('e');
if exponent < 0 {
s.push_str(&with_sign(&(-(exponent as i64)).to_string(), opts));
} else {
s.push_str(&exponent.to_string());
}
s
} else {
positional(&digits, exponent)
};
if x < 0.0 { with_sign(&body, opts) } else { body }
}
fn positional(digits: &str, exponent: i32) -> String {
if exponent < 0 {
let zeros = (-exponent - 1) as usize;
return trim_fraction(&format!("0.{}{}", "0".repeat(zeros), digits));
}
let int_len = exponent as usize + 1;
if int_len >= digits.len() {
return format!("{}{}", digits, "0".repeat(int_len - digits.len()));
}
trim_fraction(&place_point(digits, int_len))
}
fn place_point(digits: &str, int_len: usize) -> String {
format!("{}.{}", &digits[..int_len], &digits[int_len..])
}
fn trim_fraction(s: &str) -> String {
if !s.contains('.') {
return s.to_string();
}
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
fn with_sign(body: &str, opts: &FmtOpts) -> String {
let mut s = String::with_capacity(body.len() + opts.neg.len_utf8());
s.push(opts.neg);
s.push_str(body);
s
}
#[cfg(test)]
mod tests {
use super::*;
use crate::array::Buf;
use rstest::rstest;
fn j(a: &Array) -> String {
format_array(a, &FmtOpts::J)
}
fn apl(a: &Array) -> String {
format_array(a, &FmtOpts::APL)
}
fn fj(x: f64) -> String {
format_f64(x, &FmtOpts::J)
}
#[rstest]
#[case(0, "0")]
#[case(7, "7")]
#[case(-3, "_3")]
#[case(-1234, "_1234")]
#[case(i64::MIN, "_9223372036854775808")]
fn integers_j(#[case] v: i64, #[case] want: &str) {
assert_eq!(format_i64(v, &FmtOpts::J), want);
}
#[rstest]
#[case(-3, "¯3")]
#[case(3, "3")]
fn integers_apl(#[case] v: i64, #[case] want: &str) {
assert_eq!(format_i64(v, &FmtOpts::APL), want);
}
#[rstest]
#[case(0.0, "0")]
#[case(0.5, "0.5")]
#[case(2.0, "2")]
#[case(-2.0, "_2")]
#[case(1.0 / 3.0, "0.333333")]
#[case(-1.0 / 3.0, "_0.333333")]
#[case(2.0 / 3.0, "0.666667")]
#[case(1.25, "1.25")]
#[case(100.0, "100")]
#[case(1e-5, "0.00001")]
#[case(0.000012345678, "0.0000123457")]
#[case(1e11, "100000000000")]
#[case(123456789.0, "123457000")]
fn floats_positional(#[case] x: f64, #[case] want: &str) {
assert_eq!(fj(x), want);
}
#[rstest]
#[case(1e-7, "1e_7")]
#[case(-1e-7, "_1e_7")]
#[case(1.5e13, "1.5e13")]
#[case(1e12, "1e12")]
#[case(-2.5e20, "_2.5e20")]
#[case(1.234567e-9, "1.23457e_9")]
fn floats_exponent(#[case] x: f64, #[case] want: &str) {
assert_eq!(fj(x), want);
}
#[test]
fn floats_apl_signs() {
assert_eq!(format_f64(-0.5, &FmtOpts::APL), "¯0.5");
assert_eq!(format_f64(1e-7, &FmtOpts::APL), "1e¯7");
assert_eq!(format_f64(-1e-7, &FmtOpts::APL), "¯1e¯7");
}
#[test]
fn negative_zero_prints_unsigned() {
assert_eq!(fj(-0.0), "0");
}
#[test]
fn nan_and_infinities() {
assert_eq!(fj(f64::NAN), "_.");
assert_eq!(fj(f64::INFINITY), "_");
assert_eq!(fj(f64::NEG_INFINITY), "__");
assert_eq!(format_f64(f64::NAN, &FmtOpts::APL), "¯.");
assert_eq!(format_f64(f64::INFINITY, &FmtOpts::APL), "∞");
assert_eq!(format_f64(f64::NEG_INFINITY, &FmtOpts::APL), "¯∞");
}
#[test]
fn scalars() {
assert_eq!(j(&Array::scalar_i64(-3)), "_3");
assert_eq!(apl(&Array::scalar_i64(-3)), "¯3");
assert_eq!(j(&Array::scalar_f64(0.5)), "0.5");
assert_eq!(j(&Array::scalar_bool(true)), "1");
assert_eq!(j(&Array::scalar_bool(false)), "0");
assert_eq!(j(&Array::new(vec![], Data::Char(vec!['q'].into()))), "q");
}
#[test]
fn integer_vector() {
let a = Array::from_i64(vec![1, -22, 333]);
assert_eq!(j(&a), "1 _22 333");
assert_eq!(apl(&a), "1 ¯22 333");
}
#[test]
fn float_vector_trims_independently() {
let a = Array::from_f64(vec![0.5, 2.0, 1.0 / 3.0, -1e-7]);
assert_eq!(j(&a), "0.5 2 0.333333 _1e_7");
}
#[test]
fn bool_vector() {
let a = Array::new(vec![4], Data::Bool(vec![1, 0, 0, 1].into()));
assert_eq!(j(&a), "1 0 0 1");
}
#[test]
fn char_vector_is_a_plain_string() {
let a = Array::from_chars("hello".chars().collect());
assert_eq!(j(&a), "hello");
}
#[test]
fn matrix_columns_align_right() {
let a = Array::new(vec![2, 3], Data::I64(vec![1, 22, 333, 4444, 5, 66].into()));
assert_eq!(j(&a), " 1 22 333\n4444 5 66");
}
#[test]
fn matrix_negatives_widen_their_column() {
let a = Array::new(vec![2, 2], Data::I64(vec![-1, 10, 100, -2].into()));
assert_eq!(j(&a), " _1 10\n100 _2");
assert_eq!(apl(&a), " ¯1 10\n100 ¯2");
}
#[test]
fn matrix_of_floats() {
let a = Array::new(vec![2, 2], Data::F64(vec![0.5, 2.0, -1.0 / 3.0, 10.0].into()));
assert_eq!(j(&a), " 0.5 2\n_0.333333 10");
}
#[test]
fn matrix_of_bools() {
let a = Array::new(vec![2, 3], Data::Bool(vec![1, 0, 1, 0, 1, 0].into()));
assert_eq!(j(&a), "1 0 1\n0 1 0");
}
#[test]
fn single_column_matrix() {
let a = Array::new(vec![3, 1], Data::I64(vec![1, -20, 300].into()));
assert_eq!(j(&a), " 1\n_20\n300");
}
#[test]
fn rank_3_separates_planes_with_one_blank_line() {
let a = Array::new(vec![2, 2, 2], Data::I64(vec![1, 2, 3, 4, 5, 6, 7, 8].into()));
assert_eq!(j(&a), "1 2\n3 4\n\n5 6\n7 8");
}
#[test]
fn rank_3_column_widths_are_global() {
let a = Array::new(vec![2, 1, 2], Data::I64(vec![1, 2, 300, 4].into()));
assert_eq!(j(&a), " 1 2\n\n300 4");
}
#[test]
fn rank_4_separates_groups_with_two_blank_lines() {
let a = Array::new(vec![2, 2, 1, 2], Data::I64(vec![1, 2, 3, 4, 5, 6, 7, 8].into()));
assert_eq!(j(&a), "1 2\n\n3 4\n\n\n5 6\n\n7 8");
}
#[test]
fn rank_5_gap_grows_with_the_axis() {
let a = Array::new(vec![2, 1, 1, 1, 1], Data::I64(vec![1, 2].into()));
assert_eq!(j(&a), "1\n\n\n\n2");
}
#[rstest]
#[case(&[2], 1, 1)]
#[case(&[2, 3], 1, 1)]
#[case(&[2, 3], 2, 1)]
#[case(&[2, 3], 3, 2)]
#[case(&[2, 3], 4, 1)]
fn plane_gaps(#[case] frame: &[usize], #[case] p: usize, #[case] want: usize) {
assert_eq!(plane_gap(frame, p), want);
}
#[test]
fn char_matrix_is_lines() {
let a = Array::new(vec![2, 3], Data::Char("abcdef".chars().collect()));
assert_eq!(j(&a), "abc\ndef");
}
#[test]
fn char_matrix_keeps_spaces_unpadded() {
let a = Array::new(vec![2, 3], Data::Char("a bcd".chars().collect()));
assert_eq!(j(&a), "a \nbcd");
}
#[test]
fn char_rank_3_separates_planes() {
let a = Array::new(vec![2, 2, 2], Data::Char("abcdefgh".chars().collect()));
assert_eq!(j(&a), "ab\ncd\n\nef\ngh");
}
fn boxed(shape: &[usize], items: Vec<Array>) -> Array {
Array::new(shape.to_vec(), Data::Box(items.into()))
}
#[test]
fn a_box_is_drawn_as_a_fenced_cell() {
let a = boxed(&[], vec![Array::from_i64(vec![1, 2])]);
assert_eq!(j(&a), "+---+\n|1 2|\n+---+");
assert_eq!(apl(&a), " 1 2 ");
}
#[test]
fn a_boxed_vector_is_a_row_of_cells() {
let a = boxed(
&[3],
vec![
Array::scalar_i64(1),
Array::from_i64(vec![2, 3]),
Array::from_chars("abc".chars().collect()),
],
);
assert_eq!(j(&a), "+-+---+---+\n|1|2 3|abc|\n+-+---+---+");
assert_eq!(apl(&a), " 1 2 3 abc ");
}
#[test]
fn a_tall_cell_pads_the_others_below_it() {
let a = boxed(
&[2],
vec![
Array::scalar_i64(1),
Array::new(vec![2, 2], Data::I64(vec![1, 2, 3, 4].into())),
],
);
assert_eq!(j(&a), "+-+---+\n|1|1 2|\n| |3 4|\n+-+---+");
}
#[test]
fn a_nested_box_draws_inside_its_cell() {
let inner = boxed(&[], vec![Array::scalar_i64(5)]);
assert_eq!(j(&boxed(&[], vec![inner])), "+---+\n|+-+|\n||5||\n|+-+|\n+---+");
}
#[test]
fn a_box_matrix_fences_every_row() {
let a = boxed(&[2, 2], (1..=4).map(Array::scalar_i64).collect());
assert_eq!(j(&a), "+-+-+\n|1|2|\n+-+-+\n|3|4|\n+-+-+");
assert_eq!(apl(&a), "1 2\n3 4");
}
#[test]
fn a_boxed_empty_is_a_cell_of_width_zero() {
let a = boxed(&[], vec![Array::empty(DType::I64)]);
assert_eq!(j(&a), "++\n||\n++");
assert_eq!(j(&Array::new(vec![0], Data::Box(Buf::new()))), "");
}
#[rstest]
#[case(DType::Bool)]
#[case(DType::I64)]
#[case(DType::F64)]
#[case(DType::Char)]
fn empty_vectors_print_nothing(#[case] dtype: DType) {
assert_eq!(j(&Array::empty(dtype)), "");
}
#[rstest]
#[case(&[0, 3])]
#[case(&[3, 0])]
#[case(&[2, 0, 4])]
fn any_empty_axis_prints_nothing(#[case] shape: &[usize]) {
let a = Array::new(shape.to_vec(), Data::I64(vec![].into()));
assert_eq!(j(&a), "");
}
#[test]
fn no_trailing_newline_or_spaces() {
let a = Array::new(vec![2, 2, 2], Data::I64(vec![1, 22, 3, 4, 5, 6, 7, 8].into()));
let s = j(&a);
assert!(!s.ends_with('\n'));
for line in s.lines() {
assert_eq!(line.trim_end(), line, "line has trailing space: {line:?}");
}
}
}