X-Git-Url: http://git.scottworley.com/tablify/blobdiff_plain/201b9ef3e07ed5948730079475f79703b0cd28f8..d9bfcf4d1b74bebfcc9881aad4f0aacd01238b01:/src/lib.rs?ds=inline diff --git a/src/lib.rs b/src/lib.rs index b583c3e..c5761d8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,110 +1,351 @@ -#[cfg(test)] +use std::borrow::ToOwned; +use std::collections::HashMap; +use std::fmt::Write; use std::io::BufRead; -#[cfg(test)] use std::iter::Iterator; +pub struct Config {} + +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)] -struct RowInput { +enum InputLine<'a> { + Blank, + RowHeader(&'a str), + Entry(&'a str, Option<&'a str>), +} +impl<'a> From<&'a str> for InputLine<'a> { + fn from(value: &'a str) -> InputLine<'a> { + let trimmed = value.trim_end(); + if trimmed.is_empty() { + InputLine::Blank + } else if !trimmed.starts_with(' ') { + InputLine::RowHeader(value.trim()) + } else { + match value.split_once(':') { + None => InputLine::Entry(value.trim(), None), + Some((col, instance)) => InputLine::Entry(col.trim(), Some(instance.trim())), + } + } + } +} + +#[derive(Debug, PartialEq, Eq)] +struct Row { label: String, - entries: Vec, + entries: HashMap>>, } struct Reader>> { - input: Input, - row: Option, + input: std::iter::Enumerate, + row: Option, } impl>> Reader { - #[cfg(test)] fn new(input: Input) -> Self { - Self { input, row: None } + Self { + input: input.enumerate(), + row: None, + } } } impl>> Iterator for Reader { - type Item = Result; + type Item = Result; 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.is_empty() && self.row.is_some() => { - return Ok(std::mem::take(&mut self.row)).transpose() - } - Some(Ok(line)) if line.is_empty() => {} - Some(Ok(line)) if line.starts_with(' ') => match &mut self.row { - None => return Some(Err(std::io::Error::other("Entry with no header"))), - Some(ref mut row) => row.entries.push(String::from(line.trim())), - }, - Some(Ok(line)) => { - let prev = std::mem::take(&mut self.row); - self.row = Some(RowInput { - label: line, - entries: vec![], - }); - if prev.is_some() { - return Ok(prev).transpose(); + Some((_, Err(e))) => return Some(Err(e)), + Some((n, Ok(line))) => match InputLine::from(line.as_ref()) { + InputLine::Blank if self.row.is_some() => { + return Ok(std::mem::take(&mut self.row)).transpose() } - } + InputLine::Blank => {} + InputLine::Entry(col, instance) => match &mut self.row { + None => { + return Some(Err(std::io::Error::other(format!( + "{}: Entry with no header", + n + 1 + )))) + } + Some(ref mut row) => { + row.entries + .entry(col.to_owned()) + .and_modify(|is| is.push(instance.map(ToOwned::to_owned))) + .or_insert_with(|| vec![instance.map(ToOwned::to_owned)]); + } + }, + InputLine::RowHeader(row) => { + let prev = std::mem::take(&mut self.row); + self.row = Some(Row { + label: row.to_owned(), + entries: HashMap::new(), + }); + if prev.is_some() { + return Ok(prev).transpose(); + } + } + }, } } } } -#[cfg(test)] -fn read_rows(input: impl std::io::Read) -> impl Iterator> { +fn read_rows(input: impl std::io::Read) -> impl Iterator> { Reader::new(std::io::BufReader::new(input).lines()) } -pub fn tablify(_input: &impl std::io::Read) -> String { - String::from("Hello, world!") +fn column_counts(rows: &[Row]) -> Vec<(usize, String)> { + let mut counts: Vec<_> = rows + .iter() + .flat_map(|r| r.entries.keys()) + .fold(HashMap::new(), |mut cs, col| { + cs.entry(col.to_owned()) + .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: &[Row]) -> Vec { + column_counts(rows) + .into_iter() + .map(|(_, col)| col) + .collect() +} + +fn render_instance(instance: &Option) -> HTML { + match instance { + None => HTML::from("✓"), + Some(instance) => HTML::escape(instance.as_ref()), + } +} + +fn render_cell(col: &str, row: &mut Row) -> HTML { + let row_label = HTML::escape(row.label.as_ref()); + let col_label = HTML::escape(col); + let instances: Option<&Vec>> = row.entries.get(col); + let class = HTML::from(if instances.is_none() { "" } else { "yes" }); + let all_empty = instances + .iter() + .flat_map(|is| is.iter()) + .all(Option::is_none); + let contents = if instances.is_none() || (all_empty && instances.unwrap().len() == 1) { + HTML::from("") + } else if all_empty { + HTML(format!("{}", instances.unwrap().len())) + } else { + HTML( + instances + .unwrap() + .iter() + .map(render_instance) + .map(|html| html.0) // Waiting for slice_concat_trait to stabilize + .collect::>() + .join(" "), + ) + }; + row.entries.remove(col); + HTML(format!("{contents}")) +} + +fn render_row(columns: &[String], row: &mut Row) -> HTML { + 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(config: &Config, 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(|mut r| render_row(&columns, &mut r)) + .collect::() + ))) } #[cfg(test)] mod tests { use super::*; + #[test] + fn test_parse_line() { + assert_eq!(InputLine::from(""), InputLine::Blank); + assert_eq!(InputLine::from(" "), InputLine::Blank); + assert_eq!(InputLine::from("foo"), InputLine::RowHeader("foo")); + assert_eq!(InputLine::from("foo "), InputLine::RowHeader("foo")); + assert_eq!(InputLine::from(" foo"), InputLine::Entry("foo", None)); + assert_eq!( + InputLine::from(" foo:bar"), + InputLine::Entry("foo", Some("bar")) + ); + assert_eq!( + InputLine::from(" foo: bar"), + InputLine::Entry("foo", Some("bar")) + ); + assert_eq!( + InputLine::from(" foo: bar "), + InputLine::Entry("foo", Some("bar")) + ); + assert_eq!( + InputLine::from(" foo: bar "), + InputLine::Entry("foo", Some("bar")) + ); + assert_eq!( + InputLine::from(" foo : bar "), + InputLine::Entry("foo", Some("bar")) + ); + } + #[test] fn test_read_rows() { assert_eq!( read_rows(&b"foo"[..]).flatten().collect::>(), - vec![RowInput { - label: String::from("foo"), - entries: vec![] + vec![Row { + label: "foo".to_owned(), + entries: HashMap::new(), }] ); assert_eq!( read_rows(&b"bar"[..]).flatten().collect::>(), - vec![RowInput { - label: String::from("bar"), - entries: vec![] + vec![Row { + label: "bar".to_owned(), + entries: HashMap::new(), }] ); assert_eq!( read_rows(&b"foo\nbar\n"[..]).flatten().collect::>(), vec![ - RowInput { - label: String::from("foo"), - entries: vec![] + Row { + label: "foo".to_owned(), + entries: HashMap::new(), }, - RowInput { - label: String::from("bar"), - entries: vec![] + Row { + label: "bar".to_owned(), + entries: HashMap::new(), } ] ); assert_eq!( read_rows(&b"foo\n bar\n"[..]).flatten().collect::>(), - vec![RowInput { - label: String::from("foo"), - entries: vec![String::from("bar")] + vec![Row { + label: "foo".to_owned(), + entries: HashMap::from([("bar".to_owned(), vec![None])]), }] ); assert_eq!( read_rows(&b"foo\n bar\n baz\n"[..]) .flatten() .collect::>(), - vec![RowInput { - label: String::from("foo"), - entries: vec![String::from("bar"), String::from("baz")] + vec![Row { + label: "foo".to_owned(), + entries: HashMap::from([ + ("bar".to_owned(), vec![None]), + ("baz".to_owned(), vec![None]) + ]), }] ); assert_eq!( @@ -112,23 +353,181 @@ mod tests { .flatten() .collect::>(), vec![ - RowInput { - label: String::from("foo"), - entries: vec![] + Row { + label: "foo".to_owned(), + entries: HashMap::new(), }, - RowInput { - label: String::from("bar"), - entries: vec![] + Row { + label: "bar".to_owned(), + entries: HashMap::new(), } ] ); + assert_eq!( + read_rows(&b"foo\n \nbar\n"[..]) + .flatten() + .collect::>(), + vec![ + Row { + label: "foo".to_owned(), + entries: HashMap::new(), + }, + Row { + label: "bar".to_owned(), + entries: HashMap::new(), + } + ] + ); + assert_eq!( + read_rows(&b"foo \n bar \n"[..]) + .flatten() + .collect::>(), + vec![Row { + label: "foo".to_owned(), + entries: HashMap::from([("bar".to_owned(), vec![None])]), + }] + ); let bad = read_rows(&b" foo"[..]).next().unwrap(); assert!(bad.is_err()); - assert!(format!("{bad:?}").contains("Entry with no header")); + 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("Entry with no header")); + 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", + &mut Row { + label: "nope".to_owned(), + entries: HashMap::new(), + } + ), + HTML::from("") + ); + assert_eq!( + render_cell( + "foo", + &mut Row { + label: "nope".to_owned(), + entries: HashMap::from([("bar".to_owned(), vec![None])]), + } + ), + HTML::from("") + ); + assert_eq!( + render_cell( + "foo", + &mut Row { + label: "nope".to_owned(), + entries: HashMap::from([("foo".to_owned(), vec![None])]), + } + ), + HTML::from("") + ); + assert_eq!( + render_cell( + "foo", + &mut Row { + label: "nope".to_owned(), + entries: HashMap::from([("foo".to_owned(), vec![None, None])]), + } + ), + HTML::from("2") + ); + assert_eq!( + render_cell( + "foo", + &mut Row { + label: "nope".to_owned(), + entries: HashMap::from([("foo".to_owned(), vec![Some("5".to_owned()), Some("10".to_owned())])]), + } + ), + HTML::from("5 10") + ); + assert_eq!( + render_cell( + "foo", + &mut Row { + label: "nope".to_owned(), + entries: HashMap::from([("foo".to_owned(), vec![Some("5".to_owned()), None])]), + } + ), + HTML::from("5 ✓") + ); + assert_eq!( + render_cell( + "heart", + &mut Row { + label: "nope".to_owned(), + entries: HashMap::from([("heart".to_owned(), vec![Some("<3".to_owned())])]), + } + ), + HTML::from("<3") + ); + assert_eq!( + render_cell( + "foo", + &mut Row { + label: "bob's".to_owned(), + entries: HashMap::from([("foo".to_owned(), vec![None])]), + } + ), + HTML::from("") + ); + let mut r = Row { + label: "nope".to_owned(), + entries: HashMap::from([ + ("foo".to_owned(), vec![None]), + ("baz".to_owned(), vec![None]), + ]), + }; + assert_eq!(r.entries.len(), 2); + render_cell("foo", &mut r); + assert_eq!(r.entries.len(), 1); + render_cell("bar", &mut r); + assert_eq!(r.entries.len(), 1); + render_cell("baz", &mut r); + assert_eq!(r.entries.len(), 0); } }