From 2ff2d7d4f4ba93c9445e210d8da2515074ddded0 Mon Sep 17 00:00:00 2001 From: Yuval Shavit Date: Wed, 7 Aug 2024 02:49:54 -0400 Subject: [PATCH] add ability to select tables (#163) At long last! Table selectors. Syntax: ``` :-: headers matcher :-: rows matcher ``` - The `headers matcher` _must_ be specified explicitly: if you want "all columns", use `*` - Header rows are always matched, regardless of the `rows matcher` - If the table is jagged (not all rows have the same number of columns), it will be normalized by extending all short rows with empty columns. This is a departure from the official spec, which says that the number of columns is dictated by the header row, and all longer rows are truncated. I'm making that departure intentionally, because I think it's nicer, and the output I'll provide will always be valid markdown. I may later provide a switch to control that behavior. This resolves #141. --- README.md | 8 + src/fmt_md.rs | 53 +++- src/lib.rs | 1 + src/select/api.rs | 52 ++- src/select/match_selector.rs | 4 +- src/select/mod.rs | 1 + src/select/sel_block_quote.rs | 4 +- src/select/sel_code_block.rs | 4 +- src/select/sel_html.rs | 4 +- src/select/sel_image.rs | 4 +- src/select/sel_link.rs | 4 +- src/select/sel_list_item.rs | 4 +- src/select/sel_paragraph.rs | 4 +- src/select/sel_section.rs | 4 +- src/select/sel_table.rs | 245 +++++++++++++++ src/str_utils.rs | 12 +- src/tree.rs | 38 +++ src/tree_ref.rs | 419 ++++++++++++++++++++++++- src/tree_ref_serde.rs | 46 ++- src/vec_utils.rs | 128 ++++++++ tests/md_cases/select_block_quote.toml | 7 + tests/md_cases/select_tables.toml | 86 +++++ 22 files changed, 1076 insertions(+), 56 deletions(-) create mode 100644 src/select/sel_table.rs create mode 100644 src/vec_utils.rs create mode 100644 tests/md_cases/select_tables.toml diff --git a/README.md b/README.md index f7c9da3..becba2b 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,14 @@ You can select... ```bash $ cat example.md | mdq 'P: foo' # find paragraphs containing "foo" ``` + +- Tables + + ```bash + $ cat example.md | mdq ':-: "some headers" :-: "some rows"' + ``` + (Tables selection differs from other selections in that you can actually select only certain headers and rows. + See the wiki for more.) The `foo`s and `bar`s above can be: diff --git a/src/fmt_md.rs b/src/fmt_md.rs index 3844870..ac54fcd 100644 --- a/src/fmt_md.rs +++ b/src/fmt_md.rs @@ -8,7 +8,7 @@ use crate::link_transform::LinkLabel; use crate::output::{Block, Output, SimpleWrite}; use crate::str_utils::{pad_to, standard_align, CountingWriter}; use crate::tree::*; -use crate::tree_ref::{ListItemRef, MdElemRef}; +use crate::tree_ref::{ListItemRef, MdElemRef, TableSlice}; pub struct MdOptions { pub link_reference_placement: ReferencePlacement, @@ -156,7 +156,8 @@ impl<'s, 'a> MdWriterState<'s, 'a> { MdElemRef::Paragraph(para) => self.write_paragraph(out, para), MdElemRef::BlockQuote(block) => self.write_block_quote(out, block), MdElemRef::List(list) => self.write_list(out, list), - MdElemRef::Table(table) => self.write_table(out, table), + MdElemRef::Table(table) => self.write_table(out, table.into()), // TODO maybe have a generic table trait, so I don't need to do the copying? + MdElemRef::TableSlice(table) => self.write_table(out, table), MdElemRef::Inline(inline) => { self.inlines_writer.write_inline_element(out, inline); } @@ -193,15 +194,16 @@ impl<'s, 'a> MdWriterState<'s, 'a> { }); } - fn write_table(&mut self, out: &mut Output, table: &'a Table) { - let Table { alignments, rows } = table; + fn write_table(&mut self, out: &mut Output, table: TableSlice<'a>) { + let alignments = table.alignments(); + let rows = table.rows(); - let mut row_strs = Vec::with_capacity(rows.len()); + let mut row_strs = Vec::with_capacity(alignments.len()); let mut column_widths = [0].repeat(alignments.len()); if !alignments.is_empty() { for (idx, alignment) in alignments.iter().enumerate() { - let width = match standard_align(alignment) { + let width = match standard_align(*alignment) { Some(Alignment::Left | Alignment::Right) => 2, Some(Alignment::Center) => 3, None => 1, @@ -213,8 +215,8 @@ impl<'s, 'a> MdWriterState<'s, 'a> { // Pre-calculate all the cells, and also how wide each column needs to be for row in rows { let mut col_strs = Vec::with_capacity(row.len()); - for (idx, col) in row.iter().enumerate() { - let col_str = self.line_to_string(col); + for (idx, &col) in row.iter().enumerate() { + let col_str = col.map(|ln| self.line_to_string(ln)).unwrap_or("".to_string()); // Extend the row_sizes if needed. This happens if we had fewer alignments than columns in any // row. I'm not sure if that's possible, but it's easy to guard against. while column_widths.len() <= idx { @@ -266,7 +268,7 @@ impl<'s, 'a> MdWriterState<'s, 'a> { // Headers if !alignments.is_empty() { out.write_char('|'); - for (idx, align) in alignments.iter().enumerate() { + for (idx, &align) in alignments.iter().enumerate() { let width = column_widths .get(idx) .unwrap_or_else(|| match standard_align(align) { @@ -517,6 +519,7 @@ pub mod tests { BlockQuote(_), List(_), Table(_), + TableSlice(_), }); #[test] @@ -982,6 +985,38 @@ pub mod tests { | i | ii | iii |"#}, ); } + + /// Test of a table slice, instead of the table ref directly. This is just a smoke test, + /// because the implementations are the same (one forwards to the other); this test is + /// here just to validate that the delegation happens, as opposed to "oops, I forgot to + /// actually implement the delegation." + #[test] + fn slice() { + let table = Table { + alignments: vec![mdast::AlignKind::Left, mdast::AlignKind::Right], + rows: vec![ + // Header row + vec![ + // columns + vec![mdq_inline!("Left")], + vec![mdq_inline!("Right")], + ], + // Data row + vec![ + // columns + vec![mdq_inline!("a")], + vec![mdq_inline!("b")], + ], + ], + }; + check_render_refs( + vec![MdElemRef::TableSlice((&table).into())], + indoc! {r#" + | Left | Right | + |:-----|------:| + | a | b |"#}, + ); + } } mod thematic_break { diff --git a/src/lib.rs b/src/lib.rs index 0659520..481b217 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,6 +28,7 @@ mod tree_ref; mod tree_ref_serde; mod tree_test_utils; mod utils_for_test; +mod vec_utils; pub fn run_in_memory(cli: &Cli, contents: &str) -> (bool, String) { let mut out = Vec::with_capacity(256); // just a guess diff --git a/src/select/api.rs b/src/select/api.rs index 3d1d4ca..05b0dfd 100644 --- a/src/select/api.rs +++ b/src/select/api.rs @@ -9,6 +9,7 @@ use crate::select::sel_list_item::ListItemSelector; use crate::select::sel_list_item::ListItemType; use crate::select::sel_paragraph::ParagraphSelector; use crate::select::sel_section::SectionSelector; +use crate::select::sel_table::TableSliceSelector; use crate::tree::{Formatting, Inline, Link, Text, TextVariant}; use crate::tree_ref::{HtmlRef, ListItemRef, MdElemRef}; use std::fmt::{Display, Formatter}; @@ -17,7 +18,8 @@ pub type ParseResult = Result; pub const SELECTOR_SEPARATOR: char = '|'; -pub trait Selector<'a, I: Copy + Into>> { +pub trait Selector<'a, I: Into>> { + // TODO I should really rename all these 'a to 'md fn try_select(&self, item: I) -> Option>; } @@ -50,10 +52,13 @@ impl Display for ParseErrorReason { macro_rules! selectors { [ - $($(#[$meta:meta])* - $({$($char:literal $(=>$($read_variant:ident)::+)? ),+})? - $(! {$($bang_char:literal $(=>$($bang_read_variant:ident)::+)? ),+})? - $name:ident),* $(,)? + $( + $(#[$meta:meta])* + $({$($char:literal $(=>$($read_variant:ident)::+)? ),+})? + $(! {$($bang_char:literal $(=>$($bang_read_variant:ident)::+)? ),+})? + $name:ident + $(| $alias:ident)? + ),* $(,)? ] => { #[derive(Debug, PartialEq)] pub enum MdqRefSelector { @@ -68,6 +73,7 @@ macro_rules! selectors { match (self, node) { $( (Self::$name(selector), MdElemRef::$name(elem)) => selector.try_select(elem), + $( (Self::$name(selector), MdElemRef::$alias(elem)) => selector.try_select(elem.into()), )? )* _ => None } @@ -145,6 +151,8 @@ selectors![ {'`'} CodeBlock, {'<'} Html, + + {':'} TableSlice | Table, ]; impl MdqRefSelector { @@ -192,7 +200,8 @@ impl MdqRefSelector { fn build_output<'a>(&self, out: &mut Vec>, node: MdElemRef<'a>) { // try_select_node is defined in macro_helpers::selectors! - match self.try_select_node(node) { + match self.try_select_node(node.clone()) { + // TODO can we remove this? I don't think so, but let's follow up Some(found) => out.push(found), None => { for child in Self::find_children(node) { @@ -208,7 +217,7 @@ impl MdqRefSelector { /// selector-specific. For example, an [MdqNode::Section] has child nodes both in its title and in its body, but /// only the body nodes are relevant for select recursion. `MdqNode` shouldn't need to know about that oddity; it /// belongs here. - fn find_children<'a>(node: MdElemRef) -> Vec { + fn find_children(node: MdElemRef) -> Vec { match node { MdElemRef::Doc(body) => { let mut wrapped = Vec::with_capacity(body.len()); @@ -232,13 +241,18 @@ impl MdqRefSelector { } result } - MdElemRef::Table(table) => { - let count_estimate = table.rows.len() * table.rows.first().map(|tr| tr.len()).unwrap_or(0); + MdElemRef::Table(table) => Self::find_children(MdElemRef::TableSlice(table.into())), + MdElemRef::TableSlice(table) => { + let table_rows_estimate = 8; // TODO expose this from the table.rows() trait + let first_row_cols = table.rows().next().map(Vec::len).unwrap_or(0); + let count_estimate = table_rows_estimate * first_row_cols; let mut result = Vec::with_capacity(count_estimate); - for row in &table.rows { - for col in row { - for cell in col { - result.push(MdElemRef::Inline(cell)); + for row in table.rows() { + for maybe_col in row { + if let Some(col) = maybe_col { + for cell in *col { + result.push(MdElemRef::Inline(cell)); + } } } } @@ -363,6 +377,15 @@ mod test { expect_ok(mdq_ref_sel_parsed, MdqRefSelector::Paragraph(item_parsed)); } + /// See `mod sel_table::tests` for more extensive tests + #[test] + fn table_smoke() { + let input = ":-: * :-:"; + let mdq_ref_sel_parsed = MdqRefSelector::parse_selector(&mut ParsingIterator::new(input)); + let item_parsed = TableSliceSelector::read(&mut ParsingIterator::new(&input[1..])).unwrap(); + expect_ok(mdq_ref_sel_parsed, MdqRefSelector::TableSlice(item_parsed)); + } + #[test] fn unknown() { let input = "\u{2603}"; @@ -387,6 +410,7 @@ mod test { CodeBlock(_), Html(_), Paragraph(_), + TableSlice(_), }); } @@ -448,7 +472,7 @@ mod test { let inline = Inline::Link(mk_link()); let node_ref = MdElemRef::Inline(&inline); let children = MdqRefSelector::find_children(node_ref); - assert_eq!(children, vec![MdElemRef::Link(&mk_link()),]); + assert_eq!(children, vec![MdElemRef::Link(&mk_link())]); } } } diff --git a/src/select/match_selector.rs b/src/select/match_selector.rs index 604a366..ad66610 100644 --- a/src/select/match_selector.rs +++ b/src/select/match_selector.rs @@ -3,14 +3,14 @@ use crate::tree_ref::MdElemRef; /// MatchSelector is a helper trait for implementing [Selector]. Simply provide the boolean predicate for whether a /// given item matches, and MatchSelector will do the rest. -pub trait MatchSelector<'a, I: Copy + Into>> { +pub trait MatchSelector { fn matches(&self, item: I) -> bool; } impl<'a, I, M> Selector<'a, I> for M where I: Copy + Into>, - M: MatchSelector<'a, I>, + M: MatchSelector, { fn try_select(&self, item: I) -> Option> { if self.matches(item) { diff --git a/src/select/mod.rs b/src/select/mod.rs index 16eb406..e2b3da0 100644 --- a/src/select/mod.rs +++ b/src/select/mod.rs @@ -8,5 +8,6 @@ mod sel_link; mod sel_list_item; mod sel_paragraph; mod sel_section; +mod sel_table; pub use api::*; diff --git a/src/select/sel_block_quote.rs b/src/select/sel_block_quote.rs index 7e7f4ff..3bdcf07 100644 --- a/src/select/sel_block_quote.rs +++ b/src/select/sel_block_quote.rs @@ -17,8 +17,8 @@ impl BlockQuoteSelector { } } -impl<'a> MatchSelector<'a, &'a BlockQuote> for BlockQuoteSelector { - fn matches(&self, block_quote: &'a BlockQuote) -> bool { +impl MatchSelector<&BlockQuote> for BlockQuoteSelector { + fn matches(&self, block_quote: &BlockQuote) -> bool { self.matcher.matches_any(&block_quote.body) } } diff --git a/src/select/sel_code_block.rs b/src/select/sel_code_block.rs index ef1f6ed..ce937f8 100644 --- a/src/select/sel_code_block.rs +++ b/src/select/sel_code_block.rs @@ -27,8 +27,8 @@ impl CodeBlockSelector { } } -impl<'a> MatchSelector<'a, &'a CodeBlock> for CodeBlockSelector { - fn matches(&self, code_block: &'a CodeBlock) -> bool { +impl MatchSelector<&CodeBlock> for CodeBlockSelector { + fn matches(&self, code_block: &CodeBlock) -> bool { let lang_matches = match &code_block.variant { CodeVariant::Code(code_opts) => { let actual_lang = match code_opts { diff --git a/src/select/sel_html.rs b/src/select/sel_html.rs index 4aba50d..f054b4d 100644 --- a/src/select/sel_html.rs +++ b/src/select/sel_html.rs @@ -17,8 +17,8 @@ impl HtmlSelector { } } -impl<'a> MatchSelector<'a, HtmlRef<'a>> for HtmlSelector { - fn matches(&self, html: HtmlRef<'a>) -> bool { +impl MatchSelector> for HtmlSelector { + fn matches(&self, html: HtmlRef) -> bool { self.matcher.matches(html.0) } } diff --git a/src/select/sel_image.rs b/src/select/sel_image.rs index 74759cb..78d2e5e 100644 --- a/src/select/sel_image.rs +++ b/src/select/sel_image.rs @@ -16,8 +16,8 @@ impl ImageSelector { } } -impl<'a> MatchSelector<'a, &'a Image> for ImageSelector { - fn matches(&self, item: &'a Image) -> bool { +impl MatchSelector<&Image> for ImageSelector { + fn matches(&self, item: &Image) -> bool { self.matchers.display_matcher.matches(&item.alt) && self.matchers.url_matcher.matches(&item.link.url) } } diff --git a/src/select/sel_link.rs b/src/select/sel_link.rs index a098f84..744f022 100644 --- a/src/select/sel_link.rs +++ b/src/select/sel_link.rs @@ -16,8 +16,8 @@ impl LinkSelector { } } -impl<'a> MatchSelector<'a, &'a Link> for LinkSelector { - fn matches(&self, item: &'a Link) -> bool { +impl MatchSelector<&Link> for LinkSelector { + fn matches(&self, item: &Link) -> bool { self.matchers.display_matcher.matches_inlines(&item.text) && self.matchers.url_matcher.matches(&item.link_definition.url) } diff --git a/src/select/sel_list_item.rs b/src/select/sel_list_item.rs index 0cc796f..452ee10 100644 --- a/src/select/sel_list_item.rs +++ b/src/select/sel_list_item.rs @@ -82,8 +82,8 @@ impl ListItemSelector { } } -impl<'a> MatchSelector<'a, ListItemRef<'a>> for ListItemSelector { - fn matches(&self, item: ListItemRef<'a>) -> bool { +impl MatchSelector> for ListItemSelector { + fn matches(&self, item: ListItemRef) -> bool { let ListItemRef(idx, li) = item; self.li_type.matches(&idx) && self.checkbox.matches(&li.checked) && self.string_matcher.matches_any(&li.item) } diff --git a/src/select/sel_paragraph.rs b/src/select/sel_paragraph.rs index df9c9a2..3377745 100644 --- a/src/select/sel_paragraph.rs +++ b/src/select/sel_paragraph.rs @@ -18,8 +18,8 @@ impl ParagraphSelector { } } -impl<'a> MatchSelector<'a, &'a Paragraph> for ParagraphSelector { - fn matches(&self, paragraph: &'a Paragraph) -> bool { +impl MatchSelector<&Paragraph> for ParagraphSelector { + fn matches(&self, paragraph: &Paragraph) -> bool { self.matcher.matches_inlines(¶graph.body) } } diff --git a/src/select/sel_section.rs b/src/select/sel_section.rs index 3c8b314..df63d0c 100644 --- a/src/select/sel_section.rs +++ b/src/select/sel_section.rs @@ -17,8 +17,8 @@ impl SectionSelector { } } -impl<'a> MatchSelector<'a, &'a Section> for SectionSelector { - fn matches(&self, section: &'a Section) -> bool { +impl MatchSelector<&Section> for SectionSelector { + fn matches(&self, section: &Section) -> bool { self.matcher.matches_inlines(§ion.title) } } diff --git a/src/select/sel_table.rs b/src/select/sel_table.rs new file mode 100644 index 0000000..ab4ada6 --- /dev/null +++ b/src/select/sel_table.rs @@ -0,0 +1,245 @@ +use crate::matcher::StringMatcher; +use crate::parsing_iter::ParsingIterator; +use crate::select::{ParseErrorReason, ParseResult, Selector, SELECTOR_SEPARATOR}; +use crate::tree_ref::{MdElemRef, TableSlice}; + +#[derive(Debug, PartialEq)] +pub struct TableSliceSelector { + headers_matcher: StringMatcher, + rows_matcher: StringMatcher, +} + +impl TableSliceSelector { + pub fn read(iter: &mut ParsingIterator) -> ParseResult { + // headers matcher + iter.require_str("-:")?; + iter.require_whitespace(":-:")?; + if iter.peek() == Some(':') { + return Err(ParseErrorReason::InvalidSyntax( + "table headers matcher may not be empty. Use an explicit \"*\" to select all columns.".to_string(), + )); + } + let headers_matcher = StringMatcher::read(iter, ':')?; + + // rows matcher + iter.require_str(":-:")?; + iter.require_whitespace_or(SELECTOR_SEPARATOR, ":-:")?; + let rows_matcher = StringMatcher::read(iter, SELECTOR_SEPARATOR)?; + + Ok(Self { + headers_matcher, + rows_matcher, + }) + } +} + +impl<'a> Selector<'a, TableSlice<'a>> for TableSliceSelector { + fn try_select(&self, slice: TableSlice<'a>) -> Option> { + let mut slice = slice.clone(); // TODO is there any way to avoid this? There may not be. + slice.normalize(); + + slice.retain_columns_by_header(|line| self.headers_matcher.matches_inlines(line)); + if slice.is_empty() { + return None; + } + + slice.retain_rows(|line| self.rows_matcher.matches_inlines(line)); + if slice.is_empty() { + return None; + } + Some(slice.into()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod parse { + use super::*; + use crate::select::ParseErrorReason::InvalidSyntax; + + #[test] + fn only_row_matcher_provided() { + // note: no space required before the second ":-:" + expect_ok(":-: 'a':-:", "a", ".*"); + } + + #[test] + fn both_matchers_provided() { + expect_ok(":-: 'a' :-: 'b'", "a", "b"); + } + + #[test] + fn row_matcher_empty() { + expect_invalid( + ":-: :-:", + "table headers matcher may not be empty. Use an explicit \"*\" to select all columns.", + ); + } + + #[test] + fn row_matcher_no_space() { + expect_invalid(":-:X :-:", ":-: must be followed by whitespace"); + } + + fn expect_ok(in_str: &str, headers_matcher_re: &str, rows_matcher_re: &str) { + let actual_test = |actual_str: &str| { + let mut in_chars = ParsingIterator::new(actual_str); + in_chars.require_char(':').expect("test error: bad selector format"); + + let actual = TableSliceSelector::read(&mut in_chars).expect("test error: bad selector format"); + + let expect_headers_matcher = StringMatcher::from(headers_matcher_re); + let expect_rows_matcher = StringMatcher::from(rows_matcher_re); + assert_eq!(actual.headers_matcher, expect_headers_matcher); + assert_eq!(actual.rows_matcher, expect_rows_matcher); + }; + + // Run it three times: once as-is, and then with a trailing selector separator, and then + // with a space and separator. They should all be equivalent. + actual_test(in_str); + actual_test(&format!("{in_str}{SELECTOR_SEPARATOR}")); + actual_test(&format!("{in_str} {SELECTOR_SEPARATOR}")); + } + + fn expect_invalid(in_str: &str, expect_err: &str) { + let mut in_chars = ParsingIterator::new(in_str); + in_chars.require_char(':').expect("test error: bad selector format"); + + let actual = TableSliceSelector::read(&mut in_chars); + assert_eq!(actual, Err(InvalidSyntax(expect_err.to_string()))) + } + } + + mod select { + use super::*; + use crate::tree::{Inline, Line, Table, Text, TextVariant}; + use crate::unwrap; + use markdown::mdast; + + #[test] + fn select_all_on_normalized_table() { + let table: Table = Table { + alignments: vec![mdast::AlignKind::Left, mdast::AlignKind::Right], + rows: vec![ + vec![cell("header a"), cell("header b")], + vec![cell("data 1 a"), cell("data 1 b")], + ], + } + .into(); + let maybe_selected = TableSliceSelector { + headers_matcher: ".*".into(), + rows_matcher: ".*".into(), + } + .try_select((&table).into()); + + unwrap!(maybe_selected, Some(MdElemRef::TableSlice(table))); + assert_eq!( + table.alignments(), + &vec![mdast::AlignKind::Left, mdast::AlignKind::Right] + ); + assert_eq!( + table.rows().collect::>(), + vec![ + &vec![Some(&cell("header a")), Some(&cell("header b"))], + &vec![Some(&cell("data 1 a")), Some(&cell("data 1 b"))], + ] + ); + } + + #[test] + fn select_columns_on_normalized_table() { + let table: Table = Table { + alignments: vec![mdast::AlignKind::Left, mdast::AlignKind::Right], + rows: vec![ + vec![cell("header a"), cell("KEEP b")], + vec![cell("data 1 a"), cell("data 1 b")], + ], + }; + let maybe_selected = TableSliceSelector { + headers_matcher: "KEEP".into(), + rows_matcher: ".*".into(), + } + .try_select((&table).into()); + + unwrap!(maybe_selected, Some(MdElemRef::TableSlice(table))); + assert_eq!(table.alignments(), &vec![mdast::AlignKind::Right]); + assert_eq!( + table.rows().collect::>(), + vec![&vec![Some(&cell("KEEP b"))], &vec![Some(&cell("data 1 b"))],] + ); + } + + #[test] + fn select_rows_on_normalized_table() { + let table: Table = Table { + alignments: vec![mdast::AlignKind::Left, mdast::AlignKind::Right], + rows: vec![ + vec![cell("header a"), cell("header b")], + vec![cell("data 1 a"), cell("data 1 b")], + vec![cell("data 2 a"), cell("data 2 b")], + ], + }; + let maybe_selected = TableSliceSelector { + headers_matcher: ".*".into(), + rows_matcher: "data 2".into(), + } + .try_select((&table).into()); + + unwrap!(maybe_selected, Some(MdElemRef::TableSlice(table))); + assert_eq!( + table.alignments(), + &vec![mdast::AlignKind::Left, mdast::AlignKind::Right] + ); + assert_eq!( + table.rows().collect::>(), + vec![ + // note: header always gets retained + &vec![Some(&cell("header a")), Some(&cell("header b"))], + &vec![Some(&cell("data 2 a")), Some(&cell("data 2 b"))], + ] + ); + } + + /// Tests (a) that the table gets normalized, and (b) a smoke test of the matchers. + /// More extensive tests for the `retain_*` methods can be found in [TableSlice]'s tests. + #[test] + fn jagged_table() { + let table: Table = Table { + // only 1 align; rest will be filled with None + alignments: vec![mdast::AlignKind::Left], + rows: vec![ + vec![cell("header a")], + vec![cell("data 1 a"), cell("data 1 b")], + vec![cell("data 2 a"), cell("data 2 b"), cell("data 2 c")], + ], + }; + let maybe_selected = TableSliceSelector { + headers_matcher: ".*".into(), + rows_matcher: "data 1".into(), + } + .try_select((&table).into()); + + unwrap!(maybe_selected, Some(MdElemRef::TableSlice(table))); + assert_eq!( + table.alignments(), + &vec![mdast::AlignKind::Left, mdast::AlignKind::None, mdast::AlignKind::None] + ); + assert_eq!( + table.rows().collect::>(), + vec![ + &vec![Some(&cell("header a")), None, None], + &vec![Some(&cell("data 1 a")), Some(&cell("data 1 b")), None], + ] + ); + } + + fn cell(cell_str: &str) -> Line { + vec![Inline::Text(Text { + variant: TextVariant::Plain, + value: cell_str.to_string(), + })] + } + } +} diff --git a/src/str_utils.rs b/src/str_utils.rs index a63e4c5..04f4213 100644 --- a/src/str_utils.rs +++ b/src/str_utils.rs @@ -43,17 +43,17 @@ where } pub trait ToAlignment { - fn to_alignment(&self) -> Option; + fn to_alignment(self) -> Option; } impl ToAlignment for Alignment { - fn to_alignment(&self) -> Option { - Some(*self) + fn to_alignment(self) -> Option { + Some(self) } } -impl ToAlignment for &AlignKind { - fn to_alignment(&self) -> Option { +impl ToAlignment for AlignKind { + fn to_alignment(self) -> Option { match self { AlignKind::Left => Some(Alignment::Left), AlignKind::Right => Some(Alignment::Right), @@ -64,7 +64,7 @@ impl ToAlignment for &AlignKind { } impl> ToAlignment for Option { - fn to_alignment(&self) -> Option { + fn to_alignment(self) -> Option { match self { Some(a) => a.borrow().to_alignment(), None => None, diff --git a/src/tree.rs b/src/tree.rs index 021f428..9aa1a97 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -1776,6 +1776,44 @@ mod tests { }); } + #[test] + fn jagged_table() { + let (root, lookups) = parse_with( + &ParseOptions::gfm(), + indoc! {r#" + | Header A | Header B | + |:---------|---------:| + | 1 | 2 | + | 3 + | 4 | 5 | 6 | + "#}, + ); + assert_eq!(root.children.len(), 1); + check!(&root.children[0], Node::Table(_), lookups => m_node!(MdElem::Table{alignments, rows}) = { + assert_eq!(alignments, vec![mdast::AlignKind::Left, mdast::AlignKind::Right]); + assert_eq!(rows, + vec![ // rows + vec![// Header row + vec![mdq_inline!("Header A")], // cells, each being a spans of inline + vec![mdq_inline!("Header B")], + ], + vec![// data row + vec![mdq_inline!("1")], // cells, each being a spans of inline + vec![mdq_inline!("2")], + ], + vec![// data row + vec![mdq_inline!("3")], // cells, each being a spans of inline + ], + vec![// data row + vec![mdq_inline!("4")], // cells, each being a spans of inline + vec![mdq_inline!("5")], + vec![mdq_inline!("6")], + ], + ], + ); + }); + } + fn parse(md: &str) -> (mdast::Root, Lookups) { parse_with(&ParseOptions::default(), md) } diff --git a/src/tree_ref.rs b/src/tree_ref.rs index cb30ac9..e413900 100644 --- a/src/tree_ref.rs +++ b/src/tree_ref.rs @@ -1,8 +1,12 @@ -use crate::tree::{BlockQuote, CodeBlock, Image, Inline, Link, List, ListItem, MdElem, Paragraph, Section, Table}; +use crate::tree::{ + BlockQuote, CodeBlock, Image, Inline, Line, Link, List, ListItem, MdElem, Paragraph, Section, Table, +}; +use crate::vec_utils::{IndexKeeper, ItemRetainer}; +use markdown::mdast; /// An MdqNodeRef is a slice into an MdqNode tree, where each element can be outputted, and certain elements can be /// selected. -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, PartialEq)] pub enum MdElemRef<'a> { // Multiple elements that form a single area Doc(&'a Vec), @@ -22,6 +26,7 @@ pub enum MdElemRef<'a> { ListItem(ListItemRef<'a>), Link(&'a Link), Image(&'a Image), + TableSlice(TableSlice<'a>), } #[derive(Debug, Clone, Copy, PartialEq)] @@ -30,6 +35,121 @@ pub struct ListItemRef<'a>(pub Option, pub &'a ListItem); #[derive(Debug, Clone, Copy, PartialEq)] pub struct HtmlRef<'a>(pub &'a String); +#[derive(Debug, Clone, PartialEq)] +pub struct TableSlice<'a> { + alignments: Vec, + rows: Vec>, +} + +pub type TableRowSlice<'a> = Vec>; + +impl<'a> From<&'a Table> for TableSlice<'a> { + fn from(table: &'a Table) -> Self { + let alignments = table.alignments.clone(); + let mut rows = Vec::with_capacity(table.rows.len()); + for table_row in &table.rows { + let cols: Vec<_> = table_row.iter().map(Some).collect(); + rows.push(cols); + } + Self { alignments, rows } + } +} + +impl<'a> TableSlice<'a> { + pub fn alignments(&self) -> &Vec { + &self.alignments + } + + pub fn rows(&self) -> impl Iterator> { + self.rows.iter() + } + + /// Normalizes this slice, so that every row has the same number of columns. + /// + /// If the table is jagged, all jagged rows will be filled in with [None] cells. Any missing + /// alignments will be filled in as `None`. + /// This is a departure from the Markdown standard, which specifies that the first row defines + /// the number of rows, and extras are discarded. + pub fn normalize(&mut self) { + let max_cols = self.rows.iter().map(Vec::len).max().unwrap_or(0); + + for row in &mut self.rows { + let n_missing = max_cols - row.len(); + for _ in 0..n_missing { + row.push(None); + } + } + if self.alignments.len() > max_cols { + self.alignments.truncate(max_cols); + } else { + let nones = [mdast::AlignKind::None] + .iter() + .cycle() + .take(max_cols - self.alignments.len()); + self.alignments.extend(nones); + } + } + + pub fn retain_columns_by_header(&mut self, mut f: F) + where + F: FnMut(&Line) -> bool, + { + let Some(first_row) = self.rows.first() else { + return; + }; + let mut keeper_indices = IndexKeeper::new(); + keeper_indices.retain_when(first_row, |_, opt_cell| { + let empty_cell = Line::new(); + let resolved_cell = opt_cell.unwrap_or(&empty_cell); + f(resolved_cell) + }); + + match keeper_indices.count_keeps() { + 0 => { + // no columns match: clear everything out + self.alignments.clear(); + self.rows.clear(); + return; + } + n if n == first_row.len() => { + // all columns match: no need to go one by one, just return without modifications + return; + } + _ => { + // some columns match: retain those, and discard the rest + self.alignments.retain_with_index(keeper_indices.retain_fn()); + for row in self.rows.iter_mut() { + row.retain_with_index(keeper_indices.retain_fn()); + } + } + } + } + + pub fn retain_rows(&mut self, mut f: F) + where + F: FnMut(&Line) -> bool, + { + self.rows.retain_with_index(|idx, row| { + if idx == 0 { + return true; + } + row.iter().any(|opt_cell| { + let empty_cell = Line::new(); + let resolved_cell = opt_cell.unwrap_or(&empty_cell); + f(resolved_cell) + }) + }); + } + + pub fn is_empty(&self) -> bool { + // We always keep the first row; but if we then removed all the other rows, this TableSlice is empty. + if self.rows.len() <= 1 { + return true; + } + self.rows.iter().all(Vec::is_empty) + } +} + impl<'a> From<&'a MdElem> for MdElemRef<'a> { fn from(value: &'a MdElem) -> Self { match value { @@ -46,6 +166,7 @@ impl<'a> From<&'a MdElem> for MdElemRef<'a> { } } +// TODO do I need all these explicit 'a lifetimes? I think I can do '_ impl<'a> From<&'a BlockQuote> for MdElemRef<'a> { fn from(value: &'a BlockQuote) -> Self { MdElemRef::BlockQuote(value) @@ -94,6 +215,18 @@ impl<'a> From<&'a Section> for MdElemRef<'a> { } } +impl<'a> From<&'a Table> for MdElemRef<'a> { + fn from(value: &'a Table) -> Self { + MdElemRef::Table(value) + } +} + +impl<'a> From> for MdElemRef<'a> { + fn from(value: TableSlice<'a>) -> Self { + MdElemRef::TableSlice(value) + } +} + #[macro_export] macro_rules! wrap_mdq_refs { ($variant:ident: $source:expr) => {{ @@ -105,3 +238,285 @@ macro_rules! wrap_mdq_refs { result }}; } + +#[cfg(test)] +mod tests { + mod tables { + use crate::tree::{Inline, Line, Table, Text, TextVariant}; + use crate::tree_ref::TableSlice; + use markdown::mdast; + + #[test] + fn table_slice_from_table() { + let table = new_table(vec![ + vec!["header a", "header b"], + vec!["data 1 a", "data 1 b"], + vec!["data 2 a", "data 2 b"], + ]); + let slice = TableSlice::from(&table); + assert_eq!(slice.alignments, vec![mdast::AlignKind::Left, mdast::AlignKind::Right]); + assert_eq!( + slice.rows, + vec![ + vec![Some(&cell("header a")), Some(&cell("header b"))], + vec![Some(&cell("data 1 a")), Some(&cell("data 1 b"))], + vec![Some(&cell("data 2 a")), Some(&cell("data 2 b"))], + ] + ); + } + + #[test] + fn table_slice_from_table_jagged() { + let table = new_table(vec![ + vec!["header a", "header b"], + vec!["data 1 a"], + vec!["data 2 a", "data 2 b", "data 2 c"], + ]); + { + let plain_slice = TableSlice::from(&table); + assert_eq!( + plain_slice.alignments, + vec![mdast::AlignKind::Left, mdast::AlignKind::Right] + ); + assert_eq!( + plain_slice.rows, + vec![ + vec![Some(&cell("header a")), Some(&cell("header b"))], + vec![Some(&cell("data 1 a"))], + vec![ + Some(&cell("data 2 a")), + Some(&cell("data 2 b")), + Some(&cell("data 2 c")) + ], + ] + ); + } + { + let mut normalized_slice = TableSlice::from(&table); + normalized_slice.normalize(); + assert_eq!( + normalized_slice.alignments, + vec![mdast::AlignKind::Left, mdast::AlignKind::Right, mdast::AlignKind::None] + ); + assert_eq!( + normalized_slice.rows, + vec![ + vec![Some(&cell("header a")), Some(&cell("header b")), None], + vec![Some(&cell("data 1 a")), None, None], + vec![ + Some(&cell("data 2 a")), + Some(&cell("data 2 b")), + Some(&cell("data 2 c")) + ], + ] + ); + } + } + + #[test] + fn retain_col() { + let table = new_table(vec![ + vec!["KEEPER a", "header b", "header c"], + vec!["data 1 a", "data 1 b", "data 1 c"], + vec!["data 2 a", "data 2 b", "KEEPER c"], + ]); + let mut slice = TableSlice::from(&table); + slice.retain_columns_by_header(cell_matches("KEEPER")); + + // note: "KEEPER" is in the last column, but not in the header; only the header gets + // matched. + assert_eq!(slice.alignments, vec![mdast::AlignKind::Left]); + assert_eq!( + slice.rows, + vec![ + vec![Some(&cell("KEEPER a"))], + vec![Some(&cell("data 1 a"))], + vec![Some(&cell("data 2 a"))], + ] + ); + } + + #[test] + fn retain_all_columns_on_jagged_normalized_table() { + let table = new_table(vec![ + vec!["header a", "header b"], + vec!["data 1 a", "data 1 b", "data 1 c"], + vec!["data 2 a"], + ]); + let mut slice = TableSlice::from(&table); + slice.normalize(); + + let mut seen_lines = Vec::with_capacity(3); + slice.retain_columns_by_header(|line| { + seen_lines.push(simple_to_string(line)); + true + }); + + // normalization + assert_eq!( + slice.alignments, + vec![mdast::AlignKind::Left, mdast::AlignKind::Right, mdast::AlignKind::None] + ); + assert_eq!( + slice.rows, + vec![ + vec![Some(&cell("header a")), Some(&cell("header b")), None], + vec![ + Some(&cell("data 1 a")), + Some(&cell("data 1 b")), + Some(&cell("data 1 c")) + ], + vec![Some(&cell("data 2 a")), None, None], + ] + ); + assert_eq!( + seen_lines, + vec!["header a".to_string(), "header b".to_string(), "".to_string(),], + ); + } + + #[test] + fn retain_row() { + let table = new_table(vec![ + vec!["header a", "header b", "header c"], + vec!["data 1 a", "data 1 b", "data 1 c"], + vec!["data 2 a", "KEEPER b", "data 2 c"], + ]); + let mut slice = TableSlice::from(&table); + slice.retain_rows(cell_matches("KEEPER")); + + assert_eq!( + slice.alignments, + vec![ + mdast::AlignKind::Left, + mdast::AlignKind::Right, + mdast::AlignKind::Center, + ] + ); + // note: header row always gets kept + assert_eq!( + slice.rows, + vec![ + vec![ + Some(&cell("header a")), + Some(&cell("header b")), + Some(&cell("header c")) + ], + vec![ + Some(&cell("data 2 a")), + Some(&cell("KEEPER b")), + Some(&cell("data 2 c")) + ], + ] + ); + } + + #[test] + fn retain_rows_on_jagged_normalized_table() { + let table = new_table(vec![ + vec!["header a", "header b"], + vec!["data 1 a", "data 1 b", "data 1 c"], + vec!["data 2 a"], + ]); + let mut slice = TableSlice::from(&table); + slice.normalize(); + + let mut seen_lines = Vec::with_capacity(3); + // retain only the rows with empty cells. This lets us get around the short-circuiting + // of retain_rows (it short-circuits within each row as soon as it finds a matching + // cell), to validate that the normalization works as expected. + slice.retain_rows(|line| { + seen_lines.push(simple_to_string(line)); + line.is_empty() + }); + + // normalization + assert_eq!( + slice.alignments, + vec![mdast::AlignKind::Left, mdast::AlignKind::Right, mdast::AlignKind::None] + ); + assert_eq!( + slice.rows, + vec![ + vec![Some(&cell("header a")), Some(&cell("header b")), None], + vec![Some(&cell("data 2 a")), None, None], + ] + ); + assert_eq!( + seen_lines, + vec![ + // header row gets skipped, since it's always retained + // second row: + "data 1 a".to_string(), + "data 1 b".to_string(), + "data 1 c".to_string(), + // third row: note that the 2nd cell short-circuits the row, so there is no 3rd + "data 2 a".to_string(), + "".to_string(), + ], + ); + } + + fn cell_matches(substring: &str) -> impl Fn(&Line) -> bool + '_ { + move |line| { + let line_str = format!("{:?}", line); + line_str.contains(substring) + } + } + + fn new_table(cells: Vec>) -> Table { + let mut rows_iter = cells.iter().peekable(); + let Some(first_row) = rows_iter.peek() else { + return Table { + alignments: vec![], + rows: vec![], + }; + }; + + // for alignments, just cycle [L, R, C]. + let alignments = [ + mdast::AlignKind::Left, + mdast::AlignKind::Right, + mdast::AlignKind::Center, + ] + .iter() + .cycle() + .take(first_row.len()) + .map(ToOwned::to_owned) + .collect(); + let mut rows = Vec::with_capacity(cells.len()); + + while let Some(row_strings) = rows_iter.next() { + let mut row = Vec::with_capacity(row_strings.len()); + for cell_string in row_strings { + row.push(cell(cell_string)); + } + rows.push(row); + } + + Table { alignments, rows } + } + + fn cell(value: &str) -> Line { + vec![Inline::Text(Text { + variant: TextVariant::Plain, + value: value.to_string(), + })] + } + + fn simple_to_string(line: &Line) -> String { + let mut result = String::with_capacity(32); + for segment in line { + match segment { + Inline::Text(Text { variant, value }) if variant == &TextVariant::Plain => { + result.push_str(value); + } + _ => { + panic!("test error: unimplemented inline segment in simple_to_string"); + } + } + } + result + } + } +} diff --git a/src/tree_ref_serde.rs b/src/tree_ref_serde.rs index e85df1d..05b41c6 100644 --- a/src/tree_ref_serde.rs +++ b/src/tree_ref_serde.rs @@ -157,7 +157,7 @@ impl<'a> SerdeDoc<'a> { footnotes: HashMap::with_capacity(DEFAULT_CAPACITY), }; for elem in elems { - let top = SerdeElem::build(*elem, &mut inlines_writer); + let top = SerdeElem::build(elem.to_owned(), &mut inlines_writer); result.items.push(top); } for (link_label, url) in inlines_writer.drain_pending_links() { @@ -248,17 +248,22 @@ impl<'a> SerdeElem<'a> { let body = Self::build_multi(body, inlines_writer); Self::Section { depth, title, body } } - MdElemRef::Table(table) => { - let mut rendered_rows = Vec::with_capacity(table.rows.len()); - for row in &table.rows { + MdElemRef::Table(table) => Self::build(MdElemRef::TableSlice(table.into()), inlines_writer), + MdElemRef::TableSlice(table) => { + let mut rendered_rows = Vec::with_capacity(8); // TODO this is a guess; expose it via the trait? + for row in table.rows() { let mut rendered_cells = Vec::with_capacity(row.len()); - for cell in row { - rendered_cells.push(inlines_to_string(cell, inlines_writer)); + for maybe_cell in row { + let rendered_cell = match maybe_cell { + Some(cell) => inlines_to_string(*cell, inlines_writer), + None => "".to_string(), + }; + rendered_cells.push(rendered_cell) } rendered_rows.push(rendered_cells); } Self::Table { - alignments: table.alignments.iter().map(|a| a.into()).collect(), + alignments: table.alignments().iter().map(|a| a.into()).collect(), rows: rendered_rows, } } @@ -318,6 +323,7 @@ mod tests { Paragraph(_), Section(_), Table(_), + TableSlice(_), ThematicBreak, Html(_), @@ -654,6 +660,32 @@ mod tests { ); } + #[test] + fn table_slice() { + let table = Table { + alignments: vec![AlignKind::Left, AlignKind::None], + rows: vec![ + vec![vec![mdq_inline!("R1C1")], vec![mdq_inline!("R1C2")]], + vec![vec![mdq_inline!("R2C1")], vec![mdq_inline!("R2C2")]], + ], + }; + + check_md_ref( + MdElemRef::TableSlice((&table).into()), + json_str!( + {"items":[ + {"table":{ + "alignments": ["left", "none"], + "rows": [ + [ "R1C1", "R1C2" ], + [ "R2C1", "R2C2" ] + ] + }} + ]} + ), + ); + } + #[test] fn block_html() { check( diff --git a/src/vec_utils.rs b/src/vec_utils.rs new file mode 100644 index 0000000..47fb0c1 --- /dev/null +++ b/src/vec_utils.rs @@ -0,0 +1,128 @@ +use std::collections::BTreeSet; + +pub struct IndexKeeper { + indices_to_keep: BTreeSet, +} + +impl IndexKeeper { + pub fn new() -> Self { + Self { + indices_to_keep: BTreeSet::new(), + } + } + + pub fn retain_when(&mut self, items: &[I], mut allow_filter: F) + where + F: FnMut(usize, &I) -> bool, + { + for (idx, item) in items.iter().enumerate() { + if allow_filter(idx, item) { + self.indices_to_keep.insert(idx); + } + } + } + + /// Returns an `FnMut` suitable for use in [ItemRetainer::retain_with_index]. + pub fn retain_fn(&self) -> impl FnMut(usize, &I) -> bool + '_ { + let mut next_to_keep = self.indices_to_keep.iter().peekable(); + move |target, _| { + while let Some(&&value) = next_to_keep.peek() { + if value == target { + let _ = next_to_keep.next(); + return true; + } + if value > target { + return false; + } + } + false + } + } + + pub fn count_keeps(&self) -> usize { + self.indices_to_keep.len() + } +} + +pub trait ItemRetainer { + /// Iterates over the items in order, invoking `f` on each and retaining only those elements for which it returns + /// `true`. + /// + /// This is guaranteed to iterate over items sequentially, and filters can take advantage of that fact. + fn retain_with_index(&mut self, f: F) + where + F: FnMut(usize, &I) -> bool; +} + +impl ItemRetainer for Vec { + fn retain_with_index(&mut self, mut f: F) + where + F: FnMut(usize, &I) -> bool, + { + // A simple algorithm, which is O(n) in both space and time. + // I feel like there's an algorithm out there that's O(n) in time and O(1) in space, but this is good enough, + // and it's nice and simple. + let mut scratch = Vec::with_capacity(self.len()); + for (idx, item) in self.drain(..).enumerate() { + if f(idx, &item) { + scratch.push(item); + } + } + self.append(&mut scratch); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_remover() { + let mut items = vec!['a', 'b', 'c', 'd']; + let remover: IndexKeeper = [].into(); + items.retain_with_index(remover.retain_fn()); + assert_eq!(items, vec![]); + } + + #[test] + fn remover_has_bigger_indexes_than_items() { + let mut items = vec!['a', 'b', 'c', 'd']; + let remover: IndexKeeper = [0, 1, 2, 3, 4, 5, 6].into(); + items.retain_with_index(remover.retain_fn()); + assert_eq!(items, vec!['a', 'b', 'c', 'd']); + } + + #[test] + fn keep_head() { + let mut items = vec!['a', 'b', 'c', 'd']; + let remover: IndexKeeper = [0].into(); + items.retain_with_index(remover.retain_fn()); + assert_eq!(items, vec!['a']); + } + + #[test] + fn keep_middle() { + let mut items = vec!['a', 'b', 'c', 'd']; + let remover: IndexKeeper = [2].into(); + items.retain_with_index(remover.retain_fn()); + assert_eq!(items, vec!['c']); + } + + #[test] + fn keep_tail() { + let mut items = vec!['a', 'b', 'c', 'd']; + let remover: IndexKeeper = [items.len() - 1].into(); + items.retain_with_index(remover.retain_fn()); + assert_eq!(items, vec!['d']); + } + + impl From<[usize; N]> for IndexKeeper { + fn from(indices: [usize; N]) -> Self { + let mut result = Self::new(); + for idx in indices { + result.indices_to_keep.insert(idx); + } + result + } + } +} diff --git a/tests/md_cases/select_block_quote.toml b/tests/md_cases/select_block_quote.toml index e170158..5533022 100644 --- a/tests/md_cases/select_block_quote.toml +++ b/tests/md_cases/select_block_quote.toml @@ -41,3 +41,10 @@ cli_args = ['> | - *'] # note: space between the - and [ is required output = ''' - Four ''' + + +[expect."chained"] +cli_args = ['> two | > two'] # note: space between the - and [ is required +output = ''' +> Two +''' diff --git a/tests/md_cases/select_tables.toml b/tests/md_cases/select_tables.toml new file mode 100644 index 0000000..d23cd51 --- /dev/null +++ b/tests/md_cases/select_tables.toml @@ -0,0 +1,86 @@ +[given] +md = ''' +Are you ready for a table? + +| Name | Description | +|:----:|-------------| +| Foo | Not a fizz | +| Bar | Not a buzz | +| Barn | Big, red. | And this is an extra column | +| Fuzz | + +Note that the "Barn" row has an extra column, and the "Fuzz" row is missing one. +''' + + +[expect."table not normalized by default"] +cli_args = [""] +output = ''' +Are you ready for a table? + +| Name | Description | +|:----:|-------------| +| Foo | Not a fizz | +| Bar | Not a buzz | +| Barn | Big, red. | And this is an extra column | +| Fuzz | + +Note that the "Barn" row has an extra column, and the "Fuzz" row is missing one. +''' + + +[expect."select all table cells normalizes"] +cli_args = [":-: * :-:"] +output = ''' +| Name | Description | | +|:----:|-------------|-----------------------------| +| Foo | Not a fizz | | +| Bar | Not a buzz | | +| Barn | Big, red. | And this is an extra column | +| Fuzz | | |''' + + +[expect."select only name"] +# note: "Name" has an 'a', "Description" doesn't. There are other rows that do contain 'a' in the Description column, +# but the first matcher only checks the header cells (by design). +cli_args = [":-: a :-:"] +output = ''' +| Name | +|:----:| +| Foo | +| Bar | +| Barn | +| Fuzz |''' + + +[expect."select only description"] +cli_args = [":-: description :-:"] +output = ''' +| Description | +|-------------| +| Not a fizz | +| Not a buzz | +| Big, red. | +| |''' + + +[expect."select only the big red row"] +# Note: header row always survives +cli_args = [":-: * :-: 'Big, red' "] +output = ''' +| Name | Description | | +|:----:|-------------|-----------------------------| +| Barn | Big, red. | And this is an extra column |''' + + +[expect."chained"] +# chaining onen all-cells selector into another; the second should be a noop +# TODO need to add "chained" tests for all! And maybe even require it in the build.rs code +cli_args = [":-: * :-: * | :-: * :-: * | "] +output = ''' +| Name | Description | | +|:----:|-------------|-----------------------------| +| Foo | Not a fizz | | +| Bar | Not a buzz | | +| Barn | Big, red. | And this is an extra column | +| Fuzz | | |'''