diff --git a/crates/quarto-markdown-pandoc/src/pandoc/treesitter_utils/postprocess.rs b/crates/quarto-markdown-pandoc/src/pandoc/treesitter_utils/postprocess.rs index 632be54..767acde 100644 --- a/crates/quarto-markdown-pandoc/src/pandoc/treesitter_utils/postprocess.rs +++ b/crates/quarto-markdown-pandoc/src/pandoc/treesitter_utils/postprocess.rs @@ -7,7 +7,7 @@ use crate::filters::{ Filter, FilterReturn::FilterResult, FilterReturn::Unchanged, topdown_traverse, }; use crate::pandoc::attr::{Attr, is_empty_attr}; -use crate::pandoc::block::{Block, Figure, Plain}; +use crate::pandoc::block::{Block, DefinitionList, Div, Figure, Plain}; use crate::pandoc::caption::Caption; use crate::pandoc::inline::{Inline, Inlines, Space, Span, Str, Superscript}; use crate::pandoc::location::{Range, SourceInfo, empty_range, empty_source_info}; @@ -166,6 +166,99 @@ pub fn coalesce_abbreviations(inlines: Vec) -> (Vec, bool) { (result, did_coalesce) } +/// Validate that a div has the structure required for a definition list. +/// +/// Valid structure: +/// - Div must have "definition-list" class +/// - Div must contain exactly one block, which must be a BulletList +/// - Each item in the BulletList must have: +/// - Exactly two blocks +/// - First block must be Plain or Paragraph (contains the term) +/// - Second block must be a BulletList (contains the definitions) +/// +/// Returns true if valid, false otherwise. +fn is_valid_definition_list_div(div: &Div) -> bool { + // Check if div has "definition-list" class + if !div.attr.1.contains(&"definition-list".to_string()) { + return false; + } + + // Must contain exactly one block + if div.content.len() != 1 { + // FUTURE: issue linter warning: "definition-list div must contain exactly one bullet list" + return false; + } + + // That block must be a BulletList + let Block::BulletList(bullet_list) = &div.content[0] else { + // FUTURE: issue linter warning: "definition-list div must contain a bullet list" + return false; + }; + + // Check each item in the bullet list + for item_blocks in &bullet_list.content { + // Each item must have exactly 2 blocks + if item_blocks.len() != 2 { + // FUTURE: issue linter warning: "each definition list item must have a term and a nested bullet list" + return false; + } + + // First block must be Plain or Paragraph + match &item_blocks[0] { + Block::Plain(_) | Block::Paragraph(_) => {} + _ => { + // FUTURE: issue linter warning: "definition list term must be Plain or Paragraph" + return false; + } + } + + // Second block must be BulletList + if !matches!(&item_blocks[1], Block::BulletList(_)) { + // FUTURE: issue linter warning: "definitions must be in a nested bullet list" + return false; + } + } + + true +} + +/// Transform a valid definition-list div into a DefinitionList block. +/// +/// PRECONDITION: div must pass is_valid_definition_list_div() check. +/// This function uses unwrap() liberally since the structure has been pre-validated. +fn transform_definition_list_div(div: Div) -> Block { + // Extract the bullet list (validated to exist) + let Block::BulletList(bullet_list) = div.content.into_iter().next().unwrap() else { + panic!("BulletList expected after validation"); + }; + + // Transform each item into (term, definitions) tuple + let mut definition_items: Vec<(Inlines, Vec)> = Vec::new(); + + for mut item_blocks in bullet_list.content { + // Extract term from first block (Plain or Paragraph) + let term_inlines = match item_blocks.remove(0) { + Block::Plain(plain) => plain.content, + Block::Paragraph(para) => para.content, + _ => panic!("Plain or Paragraph expected after validation"), + }; + + // Extract definitions from second block (BulletList) + let Block::BulletList(definitions_list) = item_blocks.remove(0) else { + panic!("BulletList expected after validation"); + }; + + // Each item in the definitions bullet list is a definition (Vec) + definition_items.push((term_inlines, definitions_list.content)); + } + + // Preserve source location from the original div + Block::DefinitionList(DefinitionList { + content: definition_items, + source_info: div.source_info, + }) +} + /// Apply post-processing transformations to the Pandoc AST pub fn postprocess(doc: Pandoc) -> Result> { let mut errors = Vec::new(); @@ -273,6 +366,14 @@ pub fn postprocess(doc: Pandoc) -> Result> { true, ) }) + // Convert definition-list divs to DefinitionList blocks + .with_div(|div| { + if is_valid_definition_list_div(&div) { + FilterResult(vec![transform_definition_list_div(div)], false) + } else { + Unchanged(div) + } + }) .with_shortcode(|shortcode| { FilterResult(vec![Inline::Span(shortcode_to_span(shortcode))], false) }) @@ -412,6 +513,64 @@ pub fn postprocess(doc: Pandoc) -> Result> { if let Some(mut cite) = pending_cite.take() { // Add span content to the citation's suffix cite.citations[0].suffix = span.content.clone(); + + // Update the content field to include the rendered suffix with brackets + // Pandoc breaks up the bracketed suffix text by spaces, with the opening + // bracket attached to the first word and closing bracket to the last word + // e.g., "@knuth [p. 33]" becomes: Str("@knuth"), Space, Str("[p."), Space, Str("33]") + cite.content.push(Inline::Space(Space { + source_info: SourceInfo::with_range(empty_range()), + })); + + // The span content may have been merged into a single string, so we need to + // intelligently break it up to match Pandoc's behavior + let mut bracketed_content: Vec = vec![]; + for inline in &span.content { + if let Inline::Str(s) = inline { + // Split the string by spaces and create Str/Space inlines + let words: Vec<&str> = s.text.split(' ').collect(); + for (i, word) in words.iter().enumerate() { + if i > 0 { + bracketed_content.push(Inline::Space( + Space { + source_info: SourceInfo::with_range( + empty_range(), + ), + }, + )); + } + if !word.is_empty() { + bracketed_content.push(Inline::Str(Str { + text: word.to_string(), + source_info: s.source_info.clone(), + })); + } + } + } else { + bracketed_content.push(inline.clone()); + } + } + + // Now add brackets to the first and last Str elements + if !bracketed_content.is_empty() { + // Prepend "[" to the first Str element + if let Some(Inline::Str(first_str)) = + bracketed_content.first_mut() + { + first_str.text = format!("[{}", first_str.text); + } + // Append "]" to the last Str element (search from the end) + for i in (0..bracketed_content.len()).rev() { + if let Inline::Str(last_str) = + &mut bracketed_content[i] + { + last_str.text = format!("{}]", last_str.text); + break; + } + } + } + + cite.content.extend(bracketed_content); result.push(Inline::Cite(cite)); } state = 0; diff --git a/crates/quarto-markdown-pandoc/src/writers/native.rs b/crates/quarto-markdown-pandoc/src/writers/native.rs index 82cf5e2..fb58398 100644 --- a/crates/quarto-markdown-pandoc/src/writers/native.rs +++ b/crates/quarto-markdown-pandoc/src/writers/native.rs @@ -562,6 +562,32 @@ fn write_block(block: &Block, buf: &mut T) -> std::io::Result write!(buf, "] ")?; write_native_table_foot(foot, buf)?; } + Block::DefinitionList(crate::pandoc::DefinitionList { content, .. }) => { + write!(buf, "DefinitionList [")?; + for (i, (term, definitions)) in content.iter().enumerate() { + if i > 0 { + write!(buf, ", ")?; + } + write!(buf, "(")?; + write_inlines(term, buf)?; + write!(buf, ", [")?; + for (j, def_blocks) in definitions.iter().enumerate() { + if j > 0 { + write!(buf, ", ")?; + } + write!(buf, "[")?; + for (k, block) in def_blocks.iter().enumerate() { + if k > 0 { + write!(buf, ", ")?; + } + write_block(block, buf)?; + } + write!(buf, "]")?; + } + write!(buf, "])")?; + } + write!(buf, "]")?; + } _ => panic!("Unsupported block type in native writer: {:?}", block), } Ok(()) diff --git a/crates/quarto-markdown-pandoc/src/writers/qmd.rs b/crates/quarto-markdown-pandoc/src/writers/qmd.rs index 17b2835..d1b4076 100644 --- a/crates/quarto-markdown-pandoc/src/writers/qmd.rs +++ b/crates/quarto-markdown-pandoc/src/writers/qmd.rs @@ -974,6 +974,16 @@ fn write_cite(cite: &crate::pandoc::Cite, buf: &mut dyn std::io::Write) -> std:: write!(buf, "; ")?; } write!(buf, "@{}", citation.id)?; + + // Write suffix if it exists + // For AuthorInText mode, suffix appears as: @citation [suffix] + if !citation.suffix.is_empty() { + write!(buf, " [")?; + for inline in &citation.suffix { + write_inline(inline, buf)?; + } + write!(buf, "]")?; + } } } diff --git a/crates/quarto-markdown-pandoc/tests/roundtrip_tests/qmd-json-qmd/cite_with_suffix.qmd b/crates/quarto-markdown-pandoc/tests/roundtrip_tests/qmd-json-qmd/cite_with_suffix.qmd new file mode 100644 index 0000000..40148b2 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/roundtrip_tests/qmd-json-qmd/cite_with_suffix.qmd @@ -0,0 +1 @@ +@knuth [p. 33] diff --git a/crates/quarto-markdown-pandoc/tests/snapshots/native/006.qmd.snapshot b/crates/quarto-markdown-pandoc/tests/snapshots/native/006.qmd.snapshot index 419c06a..e232548 100644 --- a/crates/quarto-markdown-pandoc/tests/snapshots/native/006.qmd.snapshot +++ b/crates/quarto-markdown-pandoc/tests/snapshots/native/006.qmd.snapshot @@ -1 +1 @@ -[ Para [Cite [Citation { citationId = "smith04", citationPrefix = [], citationSuffix = [Str "p. 33"], citationMode = AuthorInText, citationNoteNum = 1, citationHash = 0 }] [Str "@smith04"]] ] \ No newline at end of file +[ Para [Cite [Citation { citationId = "smith04", citationPrefix = [], citationSuffix = [Str "p. 33"], citationMode = AuthorInText, citationNoteNum = 1, citationHash = 0 }] [Str "@smith04", Space, Str "[p. 33]"]] ] \ No newline at end of file diff --git a/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-basic.qmd b/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-basic.qmd new file mode 100644 index 0000000..6332fc9 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-basic.qmd @@ -0,0 +1,6 @@ +::: {.definition-list} +* term 1 + - definition 1 +* term 2 + - definition 2 +::: diff --git a/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-basic.qmd.snapshot b/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-basic.qmd.snapshot new file mode 100644 index 0000000..2a1de76 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-basic.qmd.snapshot @@ -0,0 +1 @@ +[ DefinitionList [([Str "term", Space, Str "1"], [[Plain [Str "definition", Space, Str "1"]]]), ([Str "term", Space, Str "2"], [[Plain [Str "definition", Space, Str "2"]]])] ] \ No newline at end of file diff --git a/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-complex-term.qmd b/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-complex-term.qmd new file mode 100644 index 0000000..209f0d3 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-complex-term.qmd @@ -0,0 +1,6 @@ +::: {.definition-list} +* **term with emphasis** + - definition for emphasized term +* `code term` + - definition for code term +::: diff --git a/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-complex-term.qmd.snapshot b/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-complex-term.qmd.snapshot new file mode 100644 index 0000000..7ee6fcc --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-complex-term.qmd.snapshot @@ -0,0 +1 @@ +[ DefinitionList [([Strong [Str "term", Space, Str "with", Space, Str "emphasis"]], [[Plain [Str "definition", Space, Str "for", Space, Str "emphasized", Space, Str "term"]]]), ([Code ( "" , [] , [] ) "code term"], [[Plain [Str "definition", Space, Str "for", Space, Str "code", Space, Str "term"]]])] ] \ No newline at end of file diff --git a/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-invalid-empty-div.qmd b/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-invalid-empty-div.qmd new file mode 100644 index 0000000..0df883b --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-invalid-empty-div.qmd @@ -0,0 +1,2 @@ +::: {.definition-list} +::: diff --git a/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-invalid-empty-div.qmd.snapshot b/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-invalid-empty-div.qmd.snapshot new file mode 100644 index 0000000..8e0de16 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-invalid-empty-div.qmd.snapshot @@ -0,0 +1 @@ +[ Div ( "" , ["definition-list"] , [] ) [] ] \ No newline at end of file diff --git a/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-invalid-extra-blocks.qmd b/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-invalid-extra-blocks.qmd new file mode 100644 index 0000000..bec6d9a --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-invalid-extra-blocks.qmd @@ -0,0 +1,7 @@ +::: {.definition-list} +* term + + extra paragraph + + - definition +::: diff --git a/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-invalid-extra-blocks.qmd.snapshot b/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-invalid-extra-blocks.qmd.snapshot new file mode 100644 index 0000000..62df7f9 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-invalid-extra-blocks.qmd.snapshot @@ -0,0 +1 @@ +[ Div ( "" , ["definition-list"] , [] ) [BulletList [[Para [Str "term"], Para [Str "extra", Space, Str "paragraph"], BulletList [[Plain [Str "definition"]]]]]] ] \ No newline at end of file diff --git a/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-invalid-no-nested-list.qmd b/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-invalid-no-nested-list.qmd new file mode 100644 index 0000000..17697a8 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-invalid-no-nested-list.qmd @@ -0,0 +1,4 @@ +::: {.definition-list} +* term without nested list +* another term +::: diff --git a/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-invalid-no-nested-list.qmd.snapshot b/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-invalid-no-nested-list.qmd.snapshot new file mode 100644 index 0000000..f5d9429 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-invalid-no-nested-list.qmd.snapshot @@ -0,0 +1 @@ +[ Div ( "" , ["definition-list"] , [] ) [BulletList [[Plain [Str "term", Space, Str "without", Space, Str "nested", Space, Str "list"]], [Plain [Str "another", Space, Str "term"]]]] ] \ No newline at end of file diff --git a/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-multiple-defs.qmd b/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-multiple-defs.qmd new file mode 100644 index 0000000..3566c38 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-multiple-defs.qmd @@ -0,0 +1,9 @@ +::: {.definition-list} +* term 1 + - definition 1a + - definition 1b +* term 2 + - definition 2a + - definition 2b + - definition 2c +::: diff --git a/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-multiple-defs.qmd.snapshot b/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-multiple-defs.qmd.snapshot new file mode 100644 index 0000000..ce22626 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/snapshots/native/definition-list-multiple-defs.qmd.snapshot @@ -0,0 +1 @@ +[ DefinitionList [([Str "term", Space, Str "1"], [[Plain [Str "definition", Space, Str "1a"]], [Plain [Str "definition", Space, Str "1b"]]]), ([Str "term", Space, Str "2"], [[Plain [Str "definition", Space, Str "2a"]], [Plain [Str "definition", Space, Str "2b"]], [Plain [Str "definition", Space, Str "2c"]]])] ] \ No newline at end of file