]> git.scottworley.com Git - tablify/blob - src/lib.rs
Release 0.2.1
[tablify] / src / lib.rs
1 use std::collections::{HashMap, HashSet};
2 use std::fmt::Write;
3 use std::io::BufRead;
4 use std::iter::Iterator;
5
6 const HEADER: &str = "<!DOCTYPE html>
7 <html>
8 <head>
9 <meta charset=\"utf-8\">
10 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
11 <style>
12 /* h/t https://wabain.github.io/2019/10/13/css-rotated-table-header.html */
13 th, td { white-space: nowrap; }
14 th { text-align: left; font-weight: normal; }
15 table { border-collapse: collapse }
16 tr.key > th { height: 10em; vertical-align: bottom; line-height: 1 }
17 tr.key > th > div { width: 1em; }
18 tr.key > th > div > div { width: 5em; transform-origin: bottom left; transform: translateX(1em) rotate(-65deg) }
19 td { border: thin solid gray; }
20 td.yes { border: thin solid gray; background-color: #ddd; }
21 /* h/t https://stackoverflow.com/questions/5687035/css-bolding-some-text-without-changing-its-containers-size/46452396#46452396 */
22 .highlight { text-shadow: -0.06ex 0 black, 0.06ex 0 black; }
23 </style>
24 <script>
25 function highlight(id) { const e = document.getElementById(id); if (e) { e.classList.add( \"highlight\"); } }
26 function clear_highlight(id) { const e = document.getElementById(id); if (e) { e.classList.remove(\"highlight\"); } }
27 function h2(a, b) { highlight(a); highlight(b); }
28 function ch2(a, b) { clear_highlight(a); clear_highlight(b); }
29 </script>
30 </head>
31 <body>
32 <table>
33 <tbody>
34 ";
35 const FOOTER: &str = " </tbody>
36 </table>
37 </body>
38 </html>";
39
40 #[derive(Debug, PartialEq, Eq, Hash)]
41 struct Entry {
42 col: String,
43 instance: Option<String>,
44 }
45 impl From<&str> for Entry {
46 fn from(value: &str) -> Entry {
47 match value.split_once(':') {
48 None => Entry {
49 col: String::from(value),
50 instance: None,
51 },
52 Some((col, instance)) => Entry {
53 col: String::from(col.trim()),
54 instance: Some(String::from(instance.trim())),
55 },
56 }
57 }
58 }
59
60 #[derive(Debug, PartialEq, Eq)]
61 struct RowInput {
62 label: String,
63 entries: Vec<Entry>,
64 }
65
66 struct Reader<Input: Iterator<Item = Result<String, std::io::Error>>> {
67 input: std::iter::Enumerate<Input>,
68 row: Option<RowInput>,
69 }
70 impl<Input: Iterator<Item = Result<String, std::io::Error>>> Reader<Input> {
71 fn new(input: Input) -> Self {
72 Self {
73 input: input.enumerate(),
74 row: None,
75 }
76 }
77 }
78 impl<Input: Iterator<Item = Result<String, std::io::Error>>> Iterator for Reader<Input> {
79 type Item = Result<RowInput, std::io::Error>;
80 fn next(&mut self) -> Option<Self::Item> {
81 loop {
82 match self
83 .input
84 .next()
85 .map(|(n, r)| (n, r.map(|line| String::from(line.trim_end()))))
86 {
87 None => return Ok(std::mem::take(&mut self.row)).transpose(),
88 Some((_, Err(e))) => return Some(Err(e)),
89 Some((_, Ok(line))) if line.is_empty() && self.row.is_some() => {
90 return Ok(std::mem::take(&mut self.row)).transpose()
91 }
92 Some((_, Ok(line))) if line.is_empty() => {}
93 Some((n, Ok(line))) if line.starts_with(' ') => match &mut self.row {
94 None => {
95 return Some(Err(std::io::Error::other(format!(
96 "{}: Entry with no header",
97 n + 1
98 ))))
99 }
100 Some(ref mut row) => row.entries.push(Entry::from(line.trim())),
101 },
102 Some((_, Ok(line))) => {
103 let prev = std::mem::take(&mut self.row);
104 self.row = Some(RowInput {
105 label: line,
106 entries: vec![],
107 });
108 if prev.is_some() {
109 return Ok(prev).transpose();
110 }
111 }
112 }
113 }
114 }
115 }
116
117 fn read_rows(input: impl std::io::Read) -> impl Iterator<Item = Result<RowInput, std::io::Error>> {
118 Reader::new(std::io::BufReader::new(input).lines())
119 }
120
121 fn column_counts(rows: &[RowInput]) -> Vec<(usize, String)> {
122 let mut counts: Vec<_> = rows
123 .iter()
124 .flat_map(|r| {
125 r.entries
126 .iter()
127 .map(|e| &e.col)
128 .collect::<HashSet<_>>()
129 .into_iter()
130 })
131 .fold(HashMap::new(), |mut cs, col| {
132 cs.entry(String::from(col))
133 .and_modify(|n| *n += 1)
134 .or_insert(1);
135 cs
136 })
137 .into_iter()
138 .map(|(col, n)| (n, col))
139 .collect();
140 counts.sort_unstable_by(|(an, acol), (bn, bcol)| bn.cmp(an).then(acol.cmp(bcol)));
141 counts
142 }
143 fn column_order(rows: &[RowInput]) -> Vec<String> {
144 column_counts(rows)
145 .into_iter()
146 .map(|(_, col)| col)
147 .collect()
148 }
149
150 fn render_instance(entry: &Entry) -> String {
151 match &entry.instance {
152 None => String::from("✓ "),
153 Some(instance) => String::from(instance) + " ",
154 }
155 }
156
157 fn render_cell(col: &str, row: &RowInput) -> String {
158 // TODO: Escape HTML special characters
159 let row_label = &row.label;
160 let entries: Vec<&Entry> = row.entries.iter().filter(|e| e.col == col).collect();
161 let class = if entries.is_empty() { "" } else { "yes" };
162 let all_empty = entries.iter().all(|e| e.instance.is_none());
163 let contents = if entries.is_empty() || (all_empty && entries.len() == 1) {
164 String::new()
165 } else if all_empty {
166 format!("{}", entries.len())
167 } else {
168 entries
169 .iter()
170 .map(|i| render_instance(i))
171 .collect::<String>()
172 };
173 format!("<td class=\"{class}\" onmouseover=\"h2('{row_label}','{col}')\" onmouseout=\"ch2('{row_label}','{col}')\">{}</td>", contents.trim())
174 }
175
176 fn render_row(columns: &[String], row: &RowInput) -> String {
177 // This is O(n^2) & doesn't need to be
178 // TODO: Escape HTML special characters
179 let row_label = &row.label;
180 format!(
181 "<tr><th id=\"{row_label}\">{row_label}</th>{}</tr>\n",
182 &columns
183 .iter()
184 .map(|col| render_cell(col, row))
185 .collect::<String>()
186 )
187 }
188
189 fn render_column_headers(columns: &[String]) -> String {
190 // TODO: Escape HTML special characters
191 String::from("<tr class=\"key\"><th></th>")
192 + &columns.iter().fold(String::new(), |mut acc, c| {
193 write!(&mut acc, "<th id=\"{c}\"><div><div>{c}</div></div></th>").unwrap();
194 acc
195 })
196 + "</tr>\n"
197 }
198
199 /// # Errors
200 ///
201 /// Will return `Err` if
202 /// * there's an i/o error while reading `input`
203 /// * the log has invalid syntax:
204 /// * an indented line with no preceding non-indented line
205 pub fn tablify(input: impl std::io::Read) -> Result<String, std::io::Error> {
206 let rows = read_rows(input).collect::<Result<Vec<_>, _>>()?;
207 let columns = column_order(&rows);
208 Ok(String::from(HEADER)
209 + &render_column_headers(&columns)
210 + &rows
211 .into_iter()
212 .map(|r| render_row(&columns, &r))
213 .collect::<String>()
214 + FOOTER)
215 }
216
217 #[cfg(test)]
218 mod tests {
219 use super::*;
220
221 #[test]
222 fn test_parse_entry() {
223 assert_eq!(
224 Entry::from("foo"),
225 Entry {
226 col: String::from("foo"),
227 instance: None
228 }
229 );
230 assert_eq!(
231 Entry::from("foo:bar"),
232 Entry {
233 col: String::from("foo"),
234 instance: Some(String::from("bar"))
235 }
236 );
237 assert_eq!(
238 Entry::from("foo: bar"),
239 Entry {
240 col: String::from("foo"),
241 instance: Some(String::from("bar"))
242 }
243 );
244 }
245
246 #[test]
247 fn test_read_rows() {
248 assert_eq!(
249 read_rows(&b"foo"[..]).flatten().collect::<Vec<_>>(),
250 vec![RowInput {
251 label: String::from("foo"),
252 entries: vec![]
253 }]
254 );
255 assert_eq!(
256 read_rows(&b"bar"[..]).flatten().collect::<Vec<_>>(),
257 vec![RowInput {
258 label: String::from("bar"),
259 entries: vec![]
260 }]
261 );
262 assert_eq!(
263 read_rows(&b"foo\nbar\n"[..]).flatten().collect::<Vec<_>>(),
264 vec![
265 RowInput {
266 label: String::from("foo"),
267 entries: vec![]
268 },
269 RowInput {
270 label: String::from("bar"),
271 entries: vec![]
272 }
273 ]
274 );
275 assert_eq!(
276 read_rows(&b"foo\n bar\n"[..]).flatten().collect::<Vec<_>>(),
277 vec![RowInput {
278 label: String::from("foo"),
279 entries: vec![Entry::from("bar")]
280 }]
281 );
282 assert_eq!(
283 read_rows(&b"foo\n bar\n baz\n"[..])
284 .flatten()
285 .collect::<Vec<_>>(),
286 vec![RowInput {
287 label: String::from("foo"),
288 entries: vec![Entry::from("bar"), Entry::from("baz")]
289 }]
290 );
291 assert_eq!(
292 read_rows(&b"foo\n\nbar\n"[..])
293 .flatten()
294 .collect::<Vec<_>>(),
295 vec![
296 RowInput {
297 label: String::from("foo"),
298 entries: vec![]
299 },
300 RowInput {
301 label: String::from("bar"),
302 entries: vec![]
303 }
304 ]
305 );
306 assert_eq!(
307 read_rows(&b"foo\n \nbar\n"[..])
308 .flatten()
309 .collect::<Vec<_>>(),
310 vec![
311 RowInput {
312 label: String::from("foo"),
313 entries: vec![]
314 },
315 RowInput {
316 label: String::from("bar"),
317 entries: vec![]
318 }
319 ]
320 );
321 assert_eq!(
322 read_rows(&b"foo \n bar \n"[..])
323 .flatten()
324 .collect::<Vec<_>>(),
325 vec![RowInput {
326 label: String::from("foo"),
327 entries: vec![Entry::from("bar")]
328 }]
329 );
330
331 let bad = read_rows(&b" foo"[..]).next().unwrap();
332 assert!(bad.is_err());
333 assert!(format!("{bad:?}").contains("1: Entry with no header"));
334
335 let bad2 = read_rows(&b"foo\n\n bar"[..]).nth(1).unwrap();
336 assert!(bad2.is_err());
337 assert!(format!("{bad2:?}").contains("3: Entry with no header"));
338 }
339
340 #[test]
341 fn test_column_counts() {
342 assert_eq!(
343 column_counts(
344 &read_rows(&b"foo\n bar\n baz\n"[..])
345 .collect::<Result<Vec<_>, _>>()
346 .unwrap()
347 ),
348 vec![(1, String::from("bar")), (1, String::from("baz"))]
349 );
350 assert_eq!(
351 column_counts(
352 &read_rows(&b"foo\n bar\n baz\nquux\n baz"[..])
353 .collect::<Result<Vec<_>, _>>()
354 .unwrap()
355 ),
356 vec![(2, String::from("baz")), (1, String::from("bar"))]
357 );
358 assert_eq!(
359 column_counts(
360 &read_rows(&b"foo\n bar\n bar\n baz\n bar\nquux\n baz"[..])
361 .collect::<Result<Vec<_>, _>>()
362 .unwrap()
363 ),
364 vec![(2, String::from("baz")), (1, String::from("bar"))]
365 );
366 assert_eq!(
367 column_counts(
368 &read_rows(&b"foo\n bar: 1\n bar: 2\n baz\n bar\nquux\n baz"[..])
369 .collect::<Result<Vec<_>, _>>()
370 .unwrap()
371 ),
372 vec![(2, String::from("baz")), (1, String::from("bar"))]
373 );
374 }
375
376 #[test]
377 fn test_render_cell() {
378 assert_eq!(
379 render_cell(
380 "foo",
381 &RowInput {
382 label: String::from("nope"),
383 entries: vec![]
384 }
385 ),
386 String::from("<td class=\"\" onmouseover=\"h2('nope','foo')\" onmouseout=\"ch2('nope','foo')\"></td>")
387 );
388 assert_eq!(
389 render_cell(
390 "foo",
391 &RowInput {
392 label: String::from("nope"),
393 entries: vec![Entry::from("bar")]
394 }
395 ),
396 String::from("<td class=\"\" onmouseover=\"h2('nope','foo')\" onmouseout=\"ch2('nope','foo')\"></td>")
397 );
398 assert_eq!(
399 render_cell(
400 "foo",
401 &RowInput {
402 label: String::from("nope"),
403 entries: vec![Entry::from("foo")]
404 }
405 ),
406 String::from("<td class=\"yes\" onmouseover=\"h2('nope','foo')\" onmouseout=\"ch2('nope','foo')\"></td>")
407 );
408 assert_eq!(
409 render_cell(
410 "foo",
411 &RowInput {
412 label: String::from("nope"),
413 entries: vec![Entry::from("foo"), Entry::from("foo")]
414 }
415 ),
416 String::from("<td class=\"yes\" onmouseover=\"h2('nope','foo')\" onmouseout=\"ch2('nope','foo')\">2</td>")
417 );
418 assert_eq!(
419 render_cell(
420 "foo",
421 &RowInput {
422 label: String::from("nope"),
423 entries: vec![Entry::from("foo: 5"), Entry::from("foo: 10")]
424 }
425 ),
426 String::from("<td class=\"yes\" onmouseover=\"h2('nope','foo')\" onmouseout=\"ch2('nope','foo')\">5 10</td>")
427 );
428 assert_eq!(
429 render_cell(
430 "foo",
431 &RowInput {
432 label: String::from("nope"),
433 entries: vec![Entry::from("foo: 5"), Entry::from("foo")]
434 }
435 ),
436 String::from("<td class=\"yes\" onmouseover=\"h2('nope','foo')\" onmouseout=\"ch2('nope','foo')\">5 ✓</td>")
437 );
438 }
439 }