sable |
| an obsidian renderer |
| git clone https://git.wayver.dev/sable |
| README | tree | log | refs |
Commit: 337ba67f65eaa17b44e371af7c0f0c761d6aa914 (tree) main
Parent: 2b84405277e54ab809e328cf0237374d4b4dbd0c (tree)
Author: wayverd
Date: 2026 M02 23, Mon 21:59:04 -0500
12 files changed; 587 insertions 75 deletions
diff --git a/sable-markdown/Cargo.toml b/sable-markdown/Cargo.toml
index 276827e..220570a 100644
version = "0.1.0"
edition = "2024"
+authors = ["Evgenii Lepikhin <[email protected]>"]
+license = "MIT"
+
workspace = ".."
[features]
diff --git a/sable-markdown/README.md b/sable-markdown/README.md
index f8049df..e64bbf3 100644
a obsidian markdown parser and renderer.
forked from [johnlepikhin/markdown-ppp](https://github.com/johnlepikhin/markdown-ppp).
+
+current upstream rev: 1e5cb4a248ee8a06d131d8a9b5823b6dcd280b07
diff --git a/sable-markdown/src/parser/inline/environment_variable.rs b/sable-markdown/src/parser/inline/environment_variable.rs
new file mode 100644
index 0000000..416d57f
+use nom::{
+ IResult, Parser,
+ branch::alt,
+ character::complete::{alpha1, alphanumeric1, char},
+ combinator::{map, recognize, verify},
+ multi::many0,
+ sequence::pair,
+};
+
+use crate::ast::Inline;
+
+pub(super) fn environment_variable(input: &str) -> IResult<&str, Inline> {
+ map(
+ verify(
+ recognize(pair(
+ alpha1,
+ many0(alt((alphanumeric1, recognize(char('_'))))),
+ )),
+ |s: &str| is_likely_env_var(s),
+ ),
+ |var_name: &str| Inline::Text(var_name.to_string()),
+ )
+ .parse(input)
+}
+
+fn is_likely_env_var(s: &str) -> bool {
+ // Must contain at least one underscore
+ if !s.contains('_') {
+ return false;
+ }
+
+ // Must not start or end with underscore
+ if s.starts_with('_') || s.ends_with('_') {
+ return false;
+ }
+
+ // Must not have consecutive underscores
+ if s.contains("__") {
+ return false;
+ }
+
+ // Should be reasonable length (heuristic)
+ if s.len() < 3 || s.len() > 50 {
+ return false;
+ }
+
+ true
+}
diff --git a/sable-markdown/src/parser/inline/image.rs b/sable-markdown/src/parser/inline/image.rs
index 727bc45..e78653a 100644
use nom::{
IResult, Parser,
- bytes::complete::take_while1,
+ bytes::complete::take_while,
character::complete::{char, multispace0},
combinator::opt,
sequence::{delimited, preceded},
move |input: &'a str| {
let (input, alt) = preceded(
char('!'),
- delimited(char('['), take_while1(|c| c != ']'), char(']')),
+ delimited(char('['), take_while(|c| c != ']'), char(']')),
)
.parse(input)?;
diff --git a/sable-markdown/src/parser/inline/mod.rs b/sable-markdown/src/parser/inline/mod.rs
index 1a4b856..849efe8 100644
mod autolink;
mod code_span;
mod emphasis;
+mod environment_variable;
mod footnote_reference;
mod hard_newline;
mod html_entity;
use crate::ast::Inline;
+/// Merges consecutive Text elements into a single Text element
+fn merge_consecutive_text_elements(inlines: Vec<Inline>) -> Vec<Inline> {
+ let mut result = Vec::new();
+ let mut current_text = String::new();
+ let mut has_text = false;
+
+ for inline in inlines {
+ match inline {
+ Inline::Text(text) => {
+ current_text.push_str(&text);
+ has_text = true;
+ }
+ other => {
+ // If we have accumulated text, add it to result
+ if has_text {
+ result.push(Inline::Text(current_text.clone()));
+ current_text.clear();
+ has_text = false;
+ }
+ // Add the non-text element
+ result.push(other);
+ }
+ }
+ }
+
+ // Don't forget the last accumulated text
+ if has_text {
+ result.push(Inline::Text(current_text));
+ }
+
+ result
+}
+
pub(super) fn inline_many0<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, Vec<Inline>> {
move |input: &'a str| {
let (input, list_of_lists) = many0(inline()).parse(input)?;
let r: Vec<_> = list_of_lists.into_iter().flatten().collect();
- Ok((input, r))
+ let merged = merge_consecutive_text_elements(r);
+ Ok((input, merged))
}
}
move |input: &'a str| {
let (input, list_of_lists) = many1(inline()).parse(input)?;
let r: Vec<_> = list_of_lists.into_iter().flatten().collect();
- Ok((input, r))
+ let merged = merge_consecutive_text_elements(r);
+ Ok((input, merged))
}
}
hard_newline::hard_newline,
image::image(),
map(code_span::code_span, Inline::Code),
+ environment_variable::environment_variable,
emphasis::emphasis(),
strikethrough::strikethrough(),
map(tag::tag(), Inline::Tag),
diff --git a/sable-markdown/src/parser/inline/tests/consecutive_text_elements.rs b/sable-markdown/src/parser/inline/tests/consecutive_text_elements.rs
new file mode 100644
index 0000000..4575c04
+//! Tests for verifying that parser correctly merges consecutive text elements
+//!
+//! These tests check cases where the parser might create multiple consecutive
+//! Text elements that should be merged into a single element.
+
+use crate::ast::*;
+use crate::parser::parse_markdown;
+
+/// Checks that there are no consecutive Text elements in the vec
+fn assert_no_consecutive_text_elements(inlines: &[Inline]) {
+ for window in inlines.windows(2) {
+ if let [Inline::Text(_), Inline::Text(_)] = window {
+ panic!("Found consecutive Text elements in {inlines:?}, which should be merged");
+ }
+ }
+
+ // Recursively check content of other elements
+ for inline in inlines {
+ match inline {
+ Inline::Emphasis(content)
+ | Inline::Strong(content)
+ | Inline::Strikethrough(content) => {
+ assert_no_consecutive_text_elements(content);
+ }
+ Inline::Link(link) => {
+ assert_no_consecutive_text_elements(&link.children);
+ }
+ Inline::LinkReference(link_ref) => {
+ assert_no_consecutive_text_elements(&link_ref.label);
+ assert_no_consecutive_text_elements(&link_ref.text);
+ }
+ _ => {}
+ }
+ }
+}
+
+/// Checks entire document for absence of consecutive Text elements
+fn assert_no_consecutive_text_in_document(doc: &Document) {
+ for block in &doc.blocks {
+ match block {
+ Block::Paragraph(inlines) => {
+ assert_no_consecutive_text_elements(inlines);
+ }
+ Block::Heading(heading) => {
+ assert_no_consecutive_text_elements(&heading.content);
+ }
+ Block::BlockQuote(blocks) => {
+ assert_no_consecutive_text_in_document(&Document {
+ blocks: blocks.clone(),
+ });
+ }
+ Block::List(list) => {
+ for item in &list.items {
+ assert_no_consecutive_text_in_document(&Document {
+ blocks: item.blocks.clone(),
+ });
+ }
+ }
+ Block::Table(table) => {
+ for row in &table.rows {
+ for cell in row {
+ assert_no_consecutive_text_elements(cell);
+ }
+ }
+ }
+ Block::FootnoteDefinition(footnote) => {
+ assert_no_consecutive_text_in_document(&Document {
+ blocks: footnote.blocks.clone(),
+ });
+ }
+ Block::Callout(callout) => {
+ assert_no_consecutive_text_in_document(&Document {
+ blocks: callout.blocks.clone(),
+ });
+ }
+ // Other blocks don't contain inline elements
+ _ => {}
+ }
+ }
+}
+
+#[test]
+fn test_environment_variables_with_text() {
+ // Environment variables between regular text
+ let doc =
+ parse_markdown("Set PKG_CONFIG_PATH and CMAKE_BUILD_TYPE to debug for testing").unwrap();
+
+ assert_no_consecutive_text_in_document(&doc);
+}
+
+#[test]
+fn test_emphasis_with_surrounding_text() {
+ // Emphasis with surrounding text
+ let doc = parse_markdown("This is *emphasized* text with more content").unwrap();
+
+ assert_no_consecutive_text_in_document(&doc);
+}
+
+#[test]
+fn test_multiple_environment_variables() {
+ // Multiple environment variables
+ let doc = parse_markdown("Use PATH_TO_FILE and CMAKE_BUILD_TYPE and PKG_CONFIG_PATH variables")
+ .unwrap();
+
+ assert_no_consecutive_text_in_document(&doc);
+}
+
+#[test]
+fn test_mixed_inline_elements() {
+ // Mix of different inline elements
+ let doc =
+ parse_markdown("Text with ENV_VAR and *emphasis* and **strong** and `code` formatting")
+ .unwrap();
+
+ assert_no_consecutive_text_in_document(&doc);
+}
+
+#[test]
+fn test_escaped_characters_with_text() {
+ // Escaped characters with text
+ let doc =
+ parse_markdown("Text with \\* escaped asterisk \\_ underscore and more text").unwrap();
+
+ assert_no_consecutive_text_in_document(&doc);
+}
+
+#[test]
+fn test_autolinks_with_text() {
+ // Autolinks with text
+ let doc =
+ parse_markdown("Visit <https://example.com> for more information about ENV_VAR usage")
+ .unwrap();
+
+ assert_no_consecutive_text_in_document(&doc);
+}
+
+#[test]
+fn test_html_entities_with_text() {
+ // HTML entities with text
+ let doc = parse_markdown("Text & more text < even more text").unwrap();
+
+ assert_no_consecutive_text_in_document(&doc);
+}
+
+#[test]
+fn test_complex_paragraph() {
+ // Complex paragraph with multiple elements
+ let doc = parse_markdown(
+ "Configure CMAKE_BUILD_TYPE to *debug* mode using PKG_CONFIG_PATH variable and check <https://example.com> for & documentation"
+ ).unwrap();
+
+ assert_no_consecutive_text_in_document(&doc);
+}
+
+#[test]
+fn test_nested_emphasis_with_env_vars() {
+ // Nested emphasis with environment variables
+ let doc = parse_markdown("Use **strong with *nested ENV_VAR emphasis* and more** formatting")
+ .unwrap();
+
+ assert_no_consecutive_text_in_document(&doc);
+}
+
+#[test]
+fn test_list_items_with_mixed_content() {
+ // List items with mixed content
+ let doc = parse_markdown(
+ "- Set ENV_VAR to *value* for testing\n- Use CMAKE_BUILD_TYPE in **production** mode\n- Check & validate settings"
+ ).unwrap();
+
+ assert_no_consecutive_text_in_document(&doc);
+}
+
+#[test]
+fn test_table_cells_with_mixed_content() {
+ // Table cells with mixed content
+ let doc = parse_markdown(
+ "| Variable | Value | Description |\n|----------|-------|-------------|\n| ENV_VAR | *debug* | Test & development |\n| PKG_CONFIG_PATH | `/usr/lib` | System **path** |"
+ ).unwrap();
+
+ assert_no_consecutive_text_in_document(&doc);
+}
+
+#[test]
+fn test_blockquote_with_mixed_content() {
+ // Blockquote with mixed content
+ let doc = parse_markdown("> Set ENV_VAR for *testing* and check PKG_CONFIG_PATH configuration")
+ .unwrap();
+
+ assert_no_consecutive_text_in_document(&doc);
+}
+
+#[test]
+fn test_heading_with_mixed_content() {
+ // Heading with mixed content
+ let doc = parse_markdown("# Configuration of ENV_VAR and *Other* Settings").unwrap();
+
+ assert_no_consecutive_text_in_document(&doc);
+}
+
+#[test]
+fn test_multiline_paragraph_with_mixed_content() {
+ // Multiline paragraph with mixed content
+ let doc = parse_markdown(
+ "First line with ENV_VAR variable\nSecond line with *emphasis* formatting\nThird line with PKG_CONFIG_PATH and more content"
+ ).unwrap();
+
+ assert_no_consecutive_text_in_document(&doc);
+}
+
+#[test]
+fn test_footnote_reference_with_text() {
+ // Footnote references with text
+ let doc = parse_markdown(
+ "Text with footnote[^1] and ENV_VAR variable\n\n[^1]: Footnote with PKG_CONFIG_PATH reference"
+ ).unwrap();
+
+ assert_no_consecutive_text_in_document(&doc);
+}
diff --git a/sable-markdown/src/parser/inline/tests/emphasis.rs b/sable-markdown/src/parser/inline/tests/emphasis.rs
new file mode 100644
index 0000000..b7f4b3d
+use crate::{ast::*, parser::parse_markdown};
+
+#[test]
+fn emphasis1() {
+ let doc = parse_markdown("*foo bar*").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Emphasis(vec![
+ Inline::Text("foo bar".to_string())
+ ])])],
+ }
+ );
+}
+
+#[test]
+fn emphasis2() {
+ let doc = parse_markdown("* a *").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::List(List {
+ kind: ListKind::Bullet(ListBulletKind::Star),
+ items: vec![ListItem {
+ task: None,
+ blocks: vec![Block::Paragraph(vec![Inline::Text("a *".to_owned())])]
+ }]
+ })]
+ }
+ );
+}
+
+#[test]
+fn emphasis3() {
+ let doc = parse_markdown("foo ___bar___").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![
+ Inline::Text("foo ".to_owned()),
+ Inline::Strong(vec![Inline::Emphasis(vec![Inline::Text("bar".to_owned())])])
+ ])]
+ }
+ );
+}
+
+#[test]
+fn emphasis4() {
+ let doc = parse_markdown("**foo ___bar___ baz**").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Strong(vec![
+ Inline::Text("foo ".to_owned()),
+ Inline::Strong(vec![Inline::Emphasis(vec![Inline::Text("bar".to_owned())])]),
+ Inline::Text(" baz".to_owned())
+ ])])]
+ }
+ );
+}
+
+#[test]
+fn emphasis_with_underscores_in_words() {
+ // Test case: PKG_CONFIG_PATH should not be parsed as PKG*CONFIG_PATH
+ let doc =
+ parse_markdown("Note that we set PKG_CONFIG_PATH only if it's not _already_ set").unwrap();
+
+ // Debug output
+ println!("Parsed document: {doc:?}");
+
+ // Expected: _already_ should be emphasized, PKG_CONFIG_PATH should remain as is
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![
+ Inline::Text("Note that we set PKG_CONFIG_PATH only if it's not ".to_string()),
+ Inline::Emphasis(vec![Inline::Text("already".to_string())]),
+ Inline::Text(" set".to_string())
+ ])],
+ }
+ );
+}
+
+#[test]
+fn test_simple_underscore() {
+ let doc = parse_markdown("_already_").unwrap();
+
+ println!("Simple underscore: {doc:?}");
+
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Emphasis(vec![
+ Inline::Text("already".to_string())
+ ])])],
+ }
+ );
+}
+
+#[test]
+fn test_pkg_config() {
+ let doc = parse_markdown("PKG_CONFIG_PATH").unwrap();
+
+ println!("PKG_CONFIG_PATH: {doc:?}");
+
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Text(
+ "PKG_CONFIG_PATH".to_string()
+ )])],
+ }
+ );
+}
+
+#[test]
+fn test_multiple_env_vars_with_emphasis() {
+ let doc =
+ parse_markdown("Set PATH_TO_FILE and CMAKE_BUILD_TYPE to _debug_ for testing").unwrap();
+
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![
+ Inline::Text("Set PATH_TO_FILE and CMAKE_BUILD_TYPE to ".to_string()),
+ Inline::Emphasis(vec![Inline::Text("debug".to_string())]),
+ Inline::Text(" for testing".to_string())
+ ])],
+ }
+ );
+}
+
+#[test]
+fn test_env_var_mixed_case() {
+ let doc = parse_markdown("Use my_custom_var for configuration").unwrap();
+
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Text(
+ "Use my_custom_var for configuration".to_string()
+ )])],
+ }
+ );
+}
+
+#[test]
+fn test_false_positive_prevention() {
+ // These should NOT be parsed as environment variables
+ let doc = parse_markdown("Text with _emphasis_ and __strong__ formatting").unwrap();
+
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![
+ Inline::Text("Text with ".to_string()),
+ Inline::Emphasis(vec![Inline::Text("emphasis".to_string())]),
+ Inline::Text(" and ".to_string()),
+ Inline::Strong(vec![Inline::Text("strong".to_string())]),
+ Inline::Text(" formatting".to_string())
+ ])],
+ }
+ );
+}
diff --git a/sable-markdown/src/parser/inline/tests/emphasis.rs b/sable-markdown/src/parser/inline/tests/emphasis.rs
deleted file mode 100644
index 6252fd0..0000000
-use crate::{ast::*, parser::parse_markdown};
-
-#[test]
-fn emphasis1() {
- let doc = parse_markdown("*foo bar*").unwrap();
- assert_eq!(
- doc,
- Document {
- blocks: vec![Block::Paragraph(vec![Inline::Emphasis(vec![
- Inline::Text("foo bar".to_string())
- ])])],
- }
- );
-}
-
-#[test]
-fn emphasis2() {
- let doc = parse_markdown("* a *").unwrap();
- assert_eq!(
- doc,
- Document {
- blocks: vec![Block::List(List {
- kind: ListKind::Bullet(ListBulletKind::Star),
- items: vec![ListItem {
- task: None,
- blocks: vec![Block::Paragraph(vec![Inline::Text("a *".to_owned())])]
- }]
- })]
- }
- );
-}
-
-#[test]
-fn emphasis3() {
- let doc = parse_markdown("foo ___bar___").unwrap();
- assert_eq!(
- doc,
- Document {
- blocks: vec![Block::Paragraph(vec![
- Inline::Text("foo ".to_owned()),
- Inline::Strong(vec![Inline::Emphasis(vec![Inline::Text("bar".to_owned())])])
- ])]
- }
- );
-}
-
-#[test]
-fn emphasis4() {
- let doc = parse_markdown("**foo ___bar___ baz**").unwrap();
- assert_eq!(
- doc,
- Document {
- blocks: vec![Block::Paragraph(vec![Inline::Strong(vec![
- Inline::Text("foo ".to_owned()),
- Inline::Strong(vec![Inline::Emphasis(vec![Inline::Text("bar".to_owned())])]),
- Inline::Text(" baz".to_owned())
- ])])]
- }
- );
-}
diff --git a/sable-markdown/src/parser/inline/tests/environment_variable.rs b/sable-markdown/src/parser/inline/tests/environment_variable.rs
new file mode 100644
index 0000000..80cc9a0
+use super::super::environment_variable::*;
+use crate::ast::*;
+
+#[test]
+fn test_basic_env_var() {
+ let result = environment_variable("PKG_CONFIG_PATH").unwrap();
+ assert_eq!(result.0, "");
+ assert_eq!(result.1, Inline::Text("PKG_CONFIG_PATH".to_string()));
+}
+
+#[test]
+fn test_mixed_case_env_var() {
+ let result = environment_variable("CMAKE_Build_Type").unwrap();
+ assert_eq!(result.0, "");
+ assert_eq!(result.1, Inline::Text("CMAKE_Build_Type".to_string()));
+}
+
+#[test]
+fn test_lowercase_env_var() {
+ let result = environment_variable("my_custom_var").unwrap();
+ assert_eq!(result.0, "");
+ assert_eq!(result.1, Inline::Text("my_custom_var".to_string()));
+}
+
+#[test]
+fn test_env_var_with_numbers() {
+ let result = environment_variable("VAR_123_test").unwrap();
+ assert_eq!(result.0, "");
+ assert_eq!(result.1, Inline::Text("VAR_123_test".to_string()));
+}
+
+#[test]
+fn test_reject_no_underscore() {
+ assert!(environment_variable("NOUNDERCORE").is_err());
+}
+
+#[test]
+fn test_reject_starts_with_underscore() {
+ assert!(environment_variable("_INVALID").is_err());
+}
+
+#[test]
+fn test_reject_ends_with_underscore() {
+ assert!(environment_variable("INVALID_").is_err());
+}
+
+#[test]
+fn test_reject_consecutive_underscores() {
+ assert!(environment_variable("INVALID__VAR").is_err());
+}
+
+#[test]
+fn test_reject_too_short() {
+ assert!(environment_variable("A_").is_err());
+ assert!(environment_variable("_A").is_err());
+}
+
+#[test]
+fn test_integration_with_full_parser() {
+ use crate::parser::parse_markdown;
+
+ let result = parse_markdown("PKG_CONFIG_PATH").unwrap();
+ println!("Integration test result: {result:?}");
+
+ // Проверяем, что PKG_CONFIG_PATH парсится как один Text элемент
+ if let Block::Paragraph(inlines) = &result.blocks[0] {
+ assert_eq!(inlines.len(), 1);
+ if let Inline::Text(text) = &inlines[0] {
+ assert_eq!(text, "PKG_CONFIG_PATH");
+ } else {
+ panic!("Expected Text, got {:?}", inlines[0]);
+ }
+ }
+}
diff --git a/sable-markdown/src/parser/inline/tests/image.rs b/sable-markdown/src/parser/inline/tests/image.rs
index de95a85..fdcf4c9 100644
}
);
}
+
+#[test]
+fn image4() {
+ let doc = parse_markdown("").unwrap();
+ assert_eq!(
+ doc,
+ Document {
+ blocks: vec![Block::Paragraph(vec![Inline::Image(Image {
+ destination: "train.jpg".to_owned(),
+ title: None,
+ alt: String::new(),
+ })])]
+ }
+ );
+}
diff --git a/sable-markdown/src/parser/inline/tests/mod.rs b/sable-markdown/src/parser/inline/tests/mod.rs
index d464e87..fc78225 100644
mod autolink;
mod code_span;
+mod consecutive_text_elements;
mod emphasis;
+mod environment_variable;
mod footnote_reference;
mod hard_newline;
mod html_entity;
diff --git a/sable-markdown/src/parser/inline/text.rs b/sable-markdown/src/parser/inline/text.rs
index 27e3d7a..9a83f72 100644
fn not_a_text<'a>() -> impl FnMut(&'a str) -> IResult<&'a str, Vec<()>> {
move |input: &'a str| {
alt((
- value((), crate::parser::inline::wikilink::wikilink()),
- value((), crate::parser::inline::autolink::autolink),
- value((), crate::parser::inline::reference_link::reference_link()),
- value((), crate::parser::inline::hard_newline::hard_newline),
- value((), crate::parser::inline::html_entity::html_entity()),
- value((), crate::parser::inline::image::image()),
- value((), crate::parser::inline::inline_link::inline_link()),
- value((), crate::parser::inline::code_span::code_span),
- value((), crate::parser::inline::emphasis::emphasis()),
+ alt((
+ value((), crate::parser::inline::wikilink::wikilink()),
+ value((), crate::parser::inline::autolink::autolink),
+ value((), crate::parser::inline::reference_link::reference_link()),
+ value((), crate::parser::inline::hard_newline::hard_newline),
+ value((), crate::parser::inline::html_entity::html_entity()),
+ value((), crate::parser::inline::image::image()),
+ )),
+ alt((
+ value((), crate::parser::inline::inline_link::inline_link()),
+ value((), crate::parser::inline::code_span::code_span),
+ value((), crate::parser::inline::emphasis::emphasis()),
+ value(
+ (),
+ crate::parser::inline::footnote_reference::footnote_reference,
+ ),
+ value((), crate::parser::inline::strikethrough::strikethrough()),
+ )),
value(
(),
- crate::parser::inline::footnote_reference::footnote_reference,
+ crate::parser::inline::environment_variable::environment_variable,
),
- value((), crate::parser::inline::strikethrough::strikethrough()),
value((), crate::parser::inline::tag::tag()),
))
.map(|v| vec![v])