]> git.scottworley.com Git - tablify/blame - src/lib.rs
Sort columns _descending_ by frequency
[tablify] / src / lib.rs
CommitLineData
397ef957 1use std::collections::{HashMap, HashSet};
7067975b 2use std::fmt::Write;
9dfa98b7 3use std::io::BufRead;
75bb888a
SW
4use std::iter::Iterator;
5
cc2378d5
SW
6const 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 }
3bc643e9 16 tr.key > th { height: 10em; vertical-align: bottom; line-height: 1 }
cc2378d5
SW
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; }
1dda21e6 20 td.yes { border: thin solid gray; background-color: #ddd; }
cc2378d5
SW
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; }
cc2378d5
SW
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>
76638ea1
SW
33 <tbody>
34";
cc2378d5
SW
35const FOOTER: &str = " </tbody>
36 </table>
37</body>
38</html>";
39
14e9852b 40#[derive(Debug, PartialEq, Eq, Hash)]
e8657dff
SW
41struct Entry {
42 col: String,
b8907770 43 instance: Option<String>,
e8657dff
SW
44}
45impl From<&str> for Entry {
46 fn from(value: &str) -> Entry {
b8907770
SW
47 match value.split_once(':') {
48 None => Entry {
49 col: String::from(value),
50 instance: None,
51 },
52 Some((col, instance)) => Entry {
0d999bc3
SW
53 col: String::from(col.trim()),
54 instance: Some(String::from(instance.trim())),
b8907770 55 },
e8657dff
SW
56 }
57 }
58}
14e9852b 59
75bb888a
SW
60#[derive(Debug, PartialEq, Eq)]
61struct RowInput {
62 label: String,
14e9852b 63 entries: Vec<Entry>,
75bb888a
SW
64}
65
201b9ef3 66struct Reader<Input: Iterator<Item = Result<String, std::io::Error>>> {
8110b492 67 input: std::iter::Enumerate<Input>,
201b9ef3
SW
68 row: Option<RowInput>,
69}
70impl<Input: Iterator<Item = Result<String, std::io::Error>>> Reader<Input> {
201b9ef3 71 fn new(input: Input) -> Self {
8110b492
SW
72 Self {
73 input: input.enumerate(),
74 row: None,
75 }
201b9ef3
SW
76 }
77}
78impl<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 {
1f6bd845
SW
82 match self
83 .input
84 .next()
8110b492 85 .map(|(n, r)| (n, r.map(|line| String::from(line.trim_end()))))
1f6bd845 86 {
201b9ef3 87 None => return Ok(std::mem::take(&mut self.row)).transpose(),
8110b492
SW
88 Some((_, Err(e))) => return Some(Err(e)),
89 Some((_, Ok(line))) if line.is_empty() && self.row.is_some() => {
201b9ef3
SW
90 return Ok(std::mem::take(&mut self.row)).transpose()
91 }
8110b492
SW
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 }
e8657dff 100 Some(ref mut row) => row.entries.push(Entry::from(line.trim())),
201b9ef3 101 },
8110b492 102 Some((_, Ok(line))) => {
201b9ef3
SW
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
201b9ef3
SW
117fn read_rows(input: impl std::io::Read) -> impl Iterator<Item = Result<RowInput, std::io::Error>> {
118 Reader::new(std::io::BufReader::new(input).lines())
75bb888a
SW
119}
120
58b5f36d
SW
121fn column_counts(rows: &[RowInput]) -> Vec<(usize, String)> {
122 let mut counts: Vec<_> = rows
123 .iter()
b8907770
SW
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))
58b5f36d 133 .and_modify(|n| *n += 1)
f272e502 134 .or_insert(1);
58b5f36d 135 cs
f272e502 136 })
58b5f36d
SW
137 .into_iter()
138 .map(|(col, n)| (n, col))
139 .collect();
38d1167a 140 counts.sort_unstable_by(|(an, acol), (bn, bcol)| bn.cmp(an).then(acol.cmp(bcol)));
58b5f36d 141 counts
f272e502 142}
d22b2e05
SW
143fn column_order(rows: &[RowInput]) -> Vec<String> {
144 column_counts(rows)
145 .into_iter()
146 .map(|(_, col)| col)
147 .collect()
148}
f272e502 149
de408c29
SW
150fn render_instance(entry: &Entry) -> String {
151 match &entry.instance {
152 None => String::from("✓ "),
153 Some(instance) => String::from(instance) + " ",
154 }
155}
156
157fn render_cell(col: &str, row: &RowInput) -> String {
158 // TODO: Escape HTML special characters
92476edc 159 let row_label = &row.label;
de408c29
SW
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 };
92476edc 173 format!("<td class=\"{class}\" onmouseover=\"h2('{row_label}','{col}')\" onmouseout=\"ch2('{row_label}','{col}')\">{}</td>", contents.trim())
de408c29
SW
174}
175
176fn render_row(columns: &[String], row: &RowInput) -> String {
177 // This is O(n^2) & doesn't need to be
178 // TODO: Escape HTML special characters
92476edc 179 let row_label = &row.label;
de408c29 180 format!(
92476edc 181 "<tr><th id=\"{row_label}\">{row_label}</th>{}</tr>\n",
de408c29
SW
182 &columns
183 .iter()
184 .map(|col| render_cell(col, row))
185 .collect::<String>()
186 )
187}
188
76638ea1
SW
189fn render_column_headers(columns: &[String]) -> String {
190 // TODO: Escape HTML special characters
92476edc 191 String::from("<tr class=\"key\"><th></th>")
7067975b 192 + &columns.iter().fold(String::new(), |mut acc, c| {
92476edc 193 write!(&mut acc, "<th id=\"{c}\"><div><div>{c}</div></div></th>").unwrap();
7067975b
SW
194 acc
195 })
92476edc 196 + "</tr>\n"
76638ea1
SW
197}
198
4b99fb70
SW
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
205pub fn tablify(input: impl std::io::Read) -> Result<String, std::io::Error> {
206 let rows = read_rows(input).collect::<Result<Vec<_>, _>>()?;
de408c29
SW
207 let columns = column_order(&rows);
208 Ok(String::from(HEADER)
76638ea1 209 + &render_column_headers(&columns)
de408c29
SW
210 + &rows
211 .into_iter()
212 .map(|r| render_row(&columns, &r))
213 .collect::<String>()
214 + FOOTER)
ece97615 215}
75bb888a
SW
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
b8907770
SW
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 );
0d999bc3
SW
237 assert_eq!(
238 Entry::from("foo: bar"),
239 Entry {
240 col: String::from("foo"),
241 instance: Some(String::from("bar"))
242 }
243 );
b8907770
SW
244 }
245
75bb888a
SW
246 #[test]
247 fn test_read_rows() {
248 assert_eq!(
201b9ef3 249 read_rows(&b"foo"[..]).flatten().collect::<Vec<_>>(),
75bb888a
SW
250 vec![RowInput {
251 label: String::from("foo"),
252 entries: vec![]
253 }]
254 );
9dfa98b7 255 assert_eq!(
201b9ef3 256 read_rows(&b"bar"[..]).flatten().collect::<Vec<_>>(),
9dfa98b7
SW
257 vec![RowInput {
258 label: String::from("bar"),
259 entries: vec![]
260 }]
261 );
2aa9ef94 262 assert_eq!(
201b9ef3 263 read_rows(&b"foo\nbar\n"[..]).flatten().collect::<Vec<_>>(),
2aa9ef94
SW
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 );
201b9ef3
SW
275 assert_eq!(
276 read_rows(&b"foo\n bar\n"[..]).flatten().collect::<Vec<_>>(),
277 vec![RowInput {
278 label: String::from("foo"),
e8657dff 279 entries: vec![Entry::from("bar")]
201b9ef3
SW
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"),
e8657dff 288 entries: vec![Entry::from("bar"), Entry::from("baz")]
201b9ef3
SW
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 );
1f6bd845
SW
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"),
e8657dff 327 entries: vec![Entry::from("bar")]
1f6bd845
SW
328 }]
329 );
201b9ef3
SW
330
331 let bad = read_rows(&b" foo"[..]).next().unwrap();
332 assert!(bad.is_err());
8110b492 333 assert!(format!("{bad:?}").contains("1: Entry with no header"));
201b9ef3
SW
334
335 let bad2 = read_rows(&b"foo\n\n bar"[..]).nth(1).unwrap();
336 assert!(bad2.is_err());
8110b492 337 assert!(format!("{bad2:?}").contains("3: Entry with no header"));
75bb888a 338 }
f272e502
SW
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 ),
58b5f36d 348 vec![(1, String::from("bar")), (1, String::from("baz"))]
f272e502
SW
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 ),
38d1167a 356 vec![(2, String::from("baz")), (1, String::from("bar"))]
f272e502 357 );
397ef957
SW
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 ),
38d1167a 364 vec![(2, String::from("baz")), (1, String::from("bar"))]
397ef957 365 );
b8907770
SW
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 ),
38d1167a 372 vec![(2, String::from("baz")), (1, String::from("bar"))]
b8907770 373 );
f272e502 374 }
de408c29
SW
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 ),
92476edc 386 String::from("<td class=\"\" onmouseover=\"h2('nope','foo')\" onmouseout=\"ch2('nope','foo')\"></td>")
de408c29
SW
387 );
388 assert_eq!(
389 render_cell(
390 "foo",
391 &RowInput {
392 label: String::from("nope"),
393 entries: vec![Entry::from("bar")]
394 }
395 ),
92476edc 396 String::from("<td class=\"\" onmouseover=\"h2('nope','foo')\" onmouseout=\"ch2('nope','foo')\"></td>")
de408c29
SW
397 );
398 assert_eq!(
399 render_cell(
400 "foo",
401 &RowInput {
402 label: String::from("nope"),
403 entries: vec![Entry::from("foo")]
404 }
405 ),
92476edc 406 String::from("<td class=\"yes\" onmouseover=\"h2('nope','foo')\" onmouseout=\"ch2('nope','foo')\"></td>")
de408c29
SW
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 ),
92476edc 416 String::from("<td class=\"yes\" onmouseover=\"h2('nope','foo')\" onmouseout=\"ch2('nope','foo')\">2</td>")
de408c29
SW
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 ),
92476edc 426 String::from("<td class=\"yes\" onmouseover=\"h2('nope','foo')\" onmouseout=\"ch2('nope','foo')\">5 10</td>")
de408c29
SW
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 ),
92476edc 436 String::from("<td class=\"yes\" onmouseover=\"h2('nope','foo')\" onmouseout=\"ch2('nope','foo')\">5 ✓</td>")
de408c29
SW
437 );
438 }
75bb888a 439}