use crate::SExp;
use std::io;
use std::io::Write;
use unicode_width::UnicodeWidthStr;
#[derive(Clone, Debug)]
pub struct PrinterConfig {
pub indent_width: usize,
pub margin_width: usize,
}
impl Default for PrinterConfig {
fn default() -> Self {
Self {
indent_width: 2,
margin_width: 80,
}
}
}
const NULL_WIDTH: usize = 2;
enum PrintPlan {
Null,
Atom(usize),
List(usize, Vec<PrintPlan>, ListPrintPlan),
}
enum ListPrintPlan {
Monoline,
Multiline,
}
impl PrintPlan {
fn width(&self) -> usize {
match self {
PrintPlan::Null => NULL_WIDTH,
PrintPlan::Atom(w) => *w,
PrintPlan::List(w, _, _) => *w,
}
}
}
pub fn write_sexp<W: Write>(w: &mut W, sexp: &SExp, config: &PrinterConfig) -> io::Result<()> {
let plan = plan(sexp, Some(config.margin_width), config);
write_impl(w, sexp, &plan, 0, config)
}
pub fn sexp_to_string(sexp: &SExp, config: &PrinterConfig) -> String {
let mut buf = Vec::new();
write_sexp(&mut buf, sexp, config).expect("writing to a Vec cannot fail");
String::from_utf8(buf).expect("printer output is valid UTF-8")
}
fn atom_width(text: &str) -> usize {
text.lines().map(UnicodeWidthStr::width).max().unwrap_or(0)
}
fn plan(sexp: &SExp, available_width: Option<usize>, config: &PrinterConfig) -> PrintPlan {
match sexp {
SExp::Null(_) => PrintPlan::Null,
SExp::Atom(v) => PrintPlan::Atom(atom_width(v)),
SExp::List(es, _) => {
assert!(
!es.is_empty(),
"cannot print an empty SExp::List; the empty list is SExp::Null"
);
let elem_plans: Vec<PrintPlan> = es.iter().map(|x| plan(x, None, config)).collect();
let monoline_width =
2 + elem_plans.iter().map(PrintPlan::width).sum::<usize>() + (es.len() - 1);
match available_width {
None => PrintPlan::List(monoline_width, elem_plans, ListPrintPlan::Monoline),
Some(available) if monoline_width <= available => {
PrintPlan::List(monoline_width, elem_plans, ListPrintPlan::Monoline)
}
Some(available) => {
let child_available = available.saturating_sub(config.indent_width);
let ml_elem_plans: Vec<PrintPlan> = es
.iter()
.map(|x| plan(x, Some(child_available), config))
.collect();
let width = config.indent_width
+ ml_elem_plans
.iter()
.map(PrintPlan::width)
.max()
.expect("list is non-empty")
+ 1;
PrintPlan::List(width, ml_elem_plans, ListPrintPlan::Multiline)
}
}
}
}
}
fn write_impl<W: Write>(
w: &mut W,
sexp: &SExp,
plan: &PrintPlan,
indent: usize,
config: &PrinterConfig,
) -> io::Result<()> {
match (sexp, plan) {
(SExp::Null(bookend_style), PrintPlan::Null) => {
write!(
w,
"{}{}",
bookend_style.open_char(),
bookend_style.close_char()
)
}
(SExp::Atom(s), PrintPlan::Atom(_)) => write!(w, "{s}"),
(SExp::List(es, bookend_style), PrintPlan::List(_, es_pps, linebreak)) => {
assert!(
!es.is_empty(),
"cannot print an empty SExp::List; the empty list is SExp::Null"
);
let insert_padding_space = matches!(
es_pps.first(),
Some(PrintPlan::List(_, _, ListPrintPlan::Multiline))
);
write!(w, "{}", bookend_style.open_char())?;
if insert_padding_space {
write!(w, " ")?;
}
let child_indent = indent + config.indent_width;
match linebreak {
ListPrintPlan::Monoline => {
for (i, (e, pp)) in es.iter().zip(es_pps).enumerate() {
if i > 0 {
write!(w, " ")?;
}
write_impl(w, e, pp, indent, config)?;
}
}
ListPrintPlan::Multiline => {
for (i, (e, pp)) in es.iter().zip(es_pps).enumerate() {
if i > 0 {
writeln!(w)?;
write!(w, "{:child_indent$}", "")?;
}
write_impl(w, e, pp, child_indent, config)?;
}
}
}
if insert_padding_space {
write!(w, " ")?;
}
write!(w, "{}", bookend_style.close_char())
}
_ => panic!("sexp-plan mismatch"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{SExpBookendStyle, parse_str};
fn fmt1(s: &str) -> String {
fmt1_with(s, &PrinterConfig::default())
}
fn fmt1_with(s: &str, config: &PrinterConfig) -> String {
let v = parse_str(s).unwrap();
assert_eq!(v.len(), 1, "expected exactly one sexp in {s:?}");
sexp_to_string(&v[0], config)
}
#[test]
fn test_null_styles() {
assert_eq!(fmt1("()"), "()");
assert_eq!(fmt1("[]"), "[]");
assert_eq!(fmt1("{}"), "{}");
}
#[test]
fn test_atom_verbatim() {
assert_eq!(fmt1("hello"), "hello");
assert_eq!(fmt1(r#""a \x41; b""#), r#""a \x41; b""#);
}
#[test]
fn test_monoline_list() {
assert_eq!(fmt1("(a b\n c)"), "(a b c)");
assert_eq!(fmt1("[a {b} ()]"), "[a {b} ()]");
}
#[test]
fn test_multiline_break() {
let long = "(word word word word word word word word word word word word word word word word)";
assert_eq!(
fmt1(long),
"(word\n word\n word\n word\n word\n word\n word\n word\n word\n word\n word\n word\n word\n word\n word\n word)"
);
}
#[test]
fn test_custom_indent() {
let config = PrinterConfig {
indent_width: 4,
margin_width: 10,
};
assert_eq!(
fmt1_with("(aaaa bbbb cccc)", &config),
"(aaaa\n bbbb\n cccc)"
);
}
#[test]
fn test_multiline_head_padding() {
let input = r#"(map ((list "avocado" "banana" "canteloupe" "durian" "eggplant" "fig" "grape" "habanero") 42))"#;
let expected = r#"(map
( (list
"avocado"
"banana"
"canteloupe"
"durian"
"eggplant"
"fig"
"grape"
"habanero")
42 ))"#;
assert_eq!(fmt1(input), expected);
}
#[test]
fn test_deeply_nested_lists_still_break() {
let inner_atoms: Vec<String> = (0..20).map(|i| format!("atom{i}")).collect();
let mut s = format!("({})", inner_atoms.join(" "));
for _ in 0..41 {
s = format!("(x {s})");
}
let out = fmt1(&s);
assert!(
!out.contains("atom0 atom1"),
"inner list should have been broken across lines:\n{out}"
);
}
#[test]
fn test_width_is_display_width_not_byte_count() {
let atom = "é".repeat(30); let s = format!("({atom} {atom})"); let out = fmt1(&s);
assert!(!out.contains('\n'), "list should be monoline: {out}");
}
#[test]
#[should_panic(expected = "empty SExp::List")]
fn test_empty_list_panics() {
sexp_to_string(
&SExp::List(Vec::new(), SExpBookendStyle::Parentheses),
&PrinterConfig::default(),
);
}
#[test]
#[should_panic(expected = "empty SExp::List")]
fn test_nested_empty_list_panics() {
sexp_to_string(
&SExp::List(
vec![SExp::List(Vec::new(), SExpBookendStyle::Parentheses)],
SExpBookendStyle::Parentheses,
),
&PrinterConfig::default(),
);
}
#[test]
fn test_multiline_string_atom_width_uses_widest_line() {
let s = "(a \"xx\nyy\" b)";
assert_eq!(fmt1(s), "(a \"xx\nyy\" b)");
}
}