use std::collections::{HashMap, HashSet}; use std::fmt::Write; use std::io::BufRead; use std::iter::Iterator; const HEADER: &str = " "; const FOOTER: &str = "
"; #[derive(PartialEq, Eq, Debug)] pub struct HTML(String); impl HTML { fn escape(value: &str) -> HTML { let mut escaped: String = String::new(); for c in value.chars() { match c { '>' => escaped.push_str(">"), '<' => escaped.push_str("<"), '\'' => escaped.push_str("'"), '"' => escaped.push_str("""), '&' => escaped.push_str("&"), ok_c => escaped.push(ok_c), } } HTML(escaped) } } impl From<&str> for HTML { fn from(value: &str) -> HTML { HTML(String::from(value)) } } impl FromIterator for HTML { fn from_iter(iter: T) -> HTML where T: IntoIterator, { HTML(iter.into_iter().map(|html| html.0).collect::()) } } impl std::fmt::Display for HTML { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } #[derive(Debug, PartialEq, Eq, Hash)] struct Entry<'a> { col: &'a str, instance: Option<&'a str>, } impl<'a> From<&'a str> for Entry<'a> { fn from(value: &'a str) -> Entry<'a> { match value.split_once(':') { None => Entry { col: value, instance: None, }, Some((col, instance)) => Entry { col: col.trim(), instance: Some(instance.trim()), }, } } } #[derive(Debug, PartialEq, Eq)] struct RowInput<'a> { label: &'a str, entries: Vec>, } struct Reader<'a, Input: Iterator>> { input: std::iter::Enumerate, row: Option>, } impl<'a, Input: Iterator>> Reader<'a, Input> { fn new(input: Input) -> Self { Self { input: input.enumerate(), row: None, } } } impl<'a, Input: Iterator>> Iterator for Reader<'a, Input> { type Item = Result, std::io::Error>; fn next(&mut self) -> Option { loop { match self.input.next() { None => return Ok(std::mem::take(&mut self.row)).transpose(), Some((_, Err(e))) => return Some(Err(e)), Some((_, Ok(line))) if line.trim_end().is_empty() && self.row.is_some() => { return Ok(std::mem::take(&mut self.row)).transpose() } Some((_, Ok(line))) if line.trim_end().is_empty() => {} Some((n, Ok(line))) if line.starts_with(' ') => match &mut self.row { None => { return Some(Err(std::io::Error::other(format!( "{}: Entry with no header", n + 1 )))) } // TODO: Don't leak Some(ref mut row) => row.entries.push(Entry::from(line.leak().trim())), }, Some((_, Ok(line))) => { let prev = std::mem::take(&mut self.row); self.row = Some(RowInput { // TODO: Don't leak label: line.leak().trim_end(), entries: vec![], }); if prev.is_some() { return Ok(prev).transpose(); } } } } } } fn read_rows( input: impl std::io::Read, ) -> impl Iterator, std::io::Error>> { Reader::new(std::io::BufReader::new(input).lines()) } fn column_counts(rows: &[RowInput]) -> Vec<(usize, String)> { let mut counts: Vec<_> = rows .iter() .flat_map(|r| { r.entries .iter() .map(|e| &e.col) .collect::>() .into_iter() }) .fold(HashMap::new(), |mut cs, col| { cs.entry(String::from(*col)) .and_modify(|n| *n += 1) .or_insert(1); cs }) .into_iter() .map(|(col, n)| (n, col)) .collect(); counts.sort_unstable_by(|(an, acol), (bn, bcol)| bn.cmp(an).then(acol.cmp(bcol))); counts } fn column_order(rows: &[RowInput]) -> Vec { column_counts(rows) .into_iter() .map(|(_, col)| col) .collect() } fn render_instance(entry: &Entry) -> HTML { match &entry.instance { None => HTML::from("✓"), Some(instance) => HTML::escape(instance.as_ref()), } } fn render_cell(col: &str, row: &RowInput) -> HTML { let row_label = HTML::escape(row.label.as_ref()); let col_label = HTML::escape(col); let entries: Vec<&Entry> = row.entries.iter().filter(|e| e.col == col).collect(); let class = HTML::from(if entries.is_empty() { "" } else { "yes" }); let all_empty = entries.iter().all(|e| e.instance.is_none()); let contents = if entries.is_empty() || (all_empty && entries.len() == 1) { HTML::from("") } else if all_empty { HTML(format!("{}", entries.len())) } else { HTML( entries .iter() .map(|i| render_instance(i)) .map(|html| html.0) // Waiting for slice_concat_trait to stabilize .collect::>() .join(" "), ) }; HTML(format!("{contents}")) } fn render_row(columns: &[String], row: &RowInput) -> HTML { // This is O(n^2) & doesn't need to be let row_label = HTML::escape(row.label.as_ref()); HTML(format!( "{row_label}{}\n", &columns .iter() .map(|col| render_cell(col, row)) .collect::() )) } fn render_column_headers(columns: &[String]) -> HTML { HTML( String::from("") + &columns.iter().fold(String::new(), |mut acc, col| { let col_header = HTML::escape(col.as_ref()); write!( &mut acc, "
{col_header}
" ) .unwrap(); acc }) + "\n", ) } /// # Errors /// /// Will return `Err` if /// * there's an i/o error while reading `input` /// * the log has invalid syntax: /// * an indented line with no preceding non-indented line pub fn tablify(input: impl std::io::Read) -> Result { let rows = read_rows(input).collect::, _>>()?; let columns = column_order(&rows); Ok(HTML(format!( "{HEADER}{}{}{FOOTER}", render_column_headers(&columns), rows.into_iter() .map(|r| render_row(&columns, &r)) .collect::() ))) } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_entry() { assert_eq!( Entry::from("foo"), Entry { col: "foo", instance: None } ); assert_eq!( Entry::from("foo:bar"), Entry { col: "foo", instance: Some("bar") } ); assert_eq!( Entry::from("foo: bar"), Entry { col: "foo", instance: Some("bar") } ); } #[test] fn test_read_rows() { assert_eq!( read_rows(&b"foo"[..]).flatten().collect::>(), vec![RowInput { label: "foo", entries: vec![] }] ); assert_eq!( read_rows(&b"bar"[..]).flatten().collect::>(), vec![RowInput { label: "bar", entries: vec![] }] ); assert_eq!( read_rows(&b"foo\nbar\n"[..]).flatten().collect::>(), vec![ RowInput { label: "foo", entries: vec![] }, RowInput { label: "bar", entries: vec![] } ] ); assert_eq!( read_rows(&b"foo\n bar\n"[..]).flatten().collect::>(), vec![RowInput { label: "foo", entries: vec![Entry::from("bar")] }] ); assert_eq!( read_rows(&b"foo\n bar\n baz\n"[..]) .flatten() .collect::>(), vec![RowInput { label: "foo", entries: vec![Entry::from("bar"), Entry::from("baz")] }] ); assert_eq!( read_rows(&b"foo\n\nbar\n"[..]) .flatten() .collect::>(), vec![ RowInput { label: "foo", entries: vec![] }, RowInput { label: "bar", entries: vec![] } ] ); assert_eq!( read_rows(&b"foo\n \nbar\n"[..]) .flatten() .collect::>(), vec![ RowInput { label: "foo", entries: vec![] }, RowInput { label: "bar", entries: vec![] } ] ); assert_eq!( read_rows(&b"foo \n bar \n"[..]) .flatten() .collect::>(), vec![RowInput { label: "foo", entries: vec![Entry::from("bar")] }] ); let bad = read_rows(&b" foo"[..]).next().unwrap(); assert!(bad.is_err()); assert!(format!("{bad:?}").contains("1: Entry with no header")); let bad2 = read_rows(&b"foo\n\n bar"[..]).nth(1).unwrap(); assert!(bad2.is_err()); assert!(format!("{bad2:?}").contains("3: Entry with no header")); } #[test] fn test_column_counts() { assert_eq!( column_counts( &read_rows(&b"foo\n bar\n baz\n"[..]) .collect::, _>>() .unwrap() ), vec![(1, String::from("bar")), (1, String::from("baz"))] ); assert_eq!( column_counts( &read_rows(&b"foo\n bar\n baz\nquux\n baz"[..]) .collect::, _>>() .unwrap() ), vec![(2, String::from("baz")), (1, String::from("bar"))] ); assert_eq!( column_counts( &read_rows(&b"foo\n bar\n bar\n baz\n bar\nquux\n baz"[..]) .collect::, _>>() .unwrap() ), vec![(2, String::from("baz")), (1, String::from("bar"))] ); assert_eq!( column_counts( &read_rows(&b"foo\n bar: 1\n bar: 2\n baz\n bar\nquux\n baz"[..]) .collect::, _>>() .unwrap() ), vec![(2, String::from("baz")), (1, String::from("bar"))] ); } #[test] fn test_render_cell() { assert_eq!( render_cell( "foo", &RowInput { label: "nope", entries: vec![] } ), HTML::from("") ); assert_eq!( render_cell( "foo", &RowInput { label: "nope", entries: vec![Entry::from("bar")] } ), HTML::from("") ); assert_eq!( render_cell( "foo", &RowInput { label: "nope", entries: vec![Entry::from("foo")] } ), HTML::from("") ); assert_eq!( render_cell( "foo", &RowInput { label: "nope", entries: vec![Entry::from("foo"), Entry::from("foo")] } ), HTML::from("2") ); assert_eq!( render_cell( "foo", &RowInput { label: "nope", entries: vec![Entry::from("foo: 5"), Entry::from("foo: 10")] } ), HTML::from("5 10") ); assert_eq!( render_cell( "foo", &RowInput { label: "nope", entries: vec![Entry::from("foo: 5"), Entry::from("foo")] } ), HTML::from("5 ✓") ); assert_eq!( render_cell( "heart", &RowInput { label: "nope", entries: vec![Entry::from("heart: <3")] } ), HTML::from("<3") ); assert_eq!( render_cell( "foo", &RowInput { label: "bob's", entries: vec![Entry::from("foo")] } ), HTML::from("") ); } }