use crate::block_parser::{BlocksFromCommentsParser, BlocksParser};
use crate::language_parsers::{
Comment, CommentsParser, TreeSitterCommentsParser, blank_preserving_line_breaks,
comment_from_node, offset_comment, xml_style_comments_parser,
};
use tree_sitter::StreamingIterator;
pub(super) fn parser() -> anyhow::Result<impl BlocksParser> {
Ok(BlocksFromCommentsParser::new(MdCommentsParser::new()))
}
struct MdCommentsParser {
md_tree_sitter_parser: tree_sitter::Parser,
md_regions_query: tree_sitter::Query,
inline_tree_sitter_parser: tree_sitter::Parser,
code_span_query: tree_sitter::Query,
html_comments_parser: TreeSitterCommentsParser,
}
impl MdCommentsParser {
fn new() -> Self {
let markdown_lang: tree_sitter::Language = tree_sitter_md::LANGUAGE.into();
let mut md_tree_sitter_parser = tree_sitter::Parser::new();
md_tree_sitter_parser
.set_language(&markdown_lang)
.expect("Error setting Tree-sitter language");
let md_regions_query = tree_sitter::Query::new(
&markdown_lang,
"(html_block) @html_block \
(inline) @inline \
(link_reference_definition) @link_reference_definition",
)
.unwrap();
let inline_lang: tree_sitter::Language = tree_sitter_md::INLINE_LANGUAGE.into();
let mut inline_tree_sitter_parser = tree_sitter::Parser::new();
inline_tree_sitter_parser
.set_language(&inline_lang)
.expect("Error setting Tree-sitter language");
let code_span_query =
tree_sitter::Query::new(&inline_lang, "(code_span) @code_span").unwrap();
let html_lang = tree_sitter_html::LANGUAGE.into();
let html_comments_parser = xml_style_comments_parser(&html_lang, "comment");
Self {
md_tree_sitter_parser,
md_regions_query,
inline_tree_sitter_parser,
code_span_query,
html_comments_parser,
}
}
fn parse_comments(&mut self, contents: &str) -> Vec<Comment> {
let tree = self.md_tree_sitter_parser.parse(contents, None).unwrap();
let mut query_cursor = tree_sitter::QueryCursor::new();
let mut matches = query_cursor.matches(
&self.md_regions_query,
tree.root_node(),
contents.as_bytes(),
);
let mut comments = Vec::new();
while let Some(query_match) = matches.next() {
let node = query_match
.captures
.first()
.expect("Empty Tree-sitter region query match")
.node;
let region = &contents[node.byte_range()];
if node.kind() == "link_reference_definition" {
if let Some(text) = link_reference_definition_comment_text(region) {
comments.push(comment_from_node(&node, text));
}
continue;
}
if !region.contains("<!--") {
continue;
}
let html_comments: Vec<Comment> = if node.kind() == "inline" && region.contains('`') {
let view = Self::inline_html_view(
&mut self.inline_tree_sitter_parser,
&self.code_span_query,
region,
);
self.html_comments_parser.parse(&view).collect()
} else {
self.html_comments_parser.parse(region).collect()
};
for mut comment in html_comments {
offset_comment(&mut comment, &node);
comments.push(comment);
}
}
comments
}
fn inline_html_view(
inline_tree_sitter_parser: &mut tree_sitter::Parser,
code_span_query: &tree_sitter::Query,
region: &str,
) -> String {
let mut view = region.as_bytes().to_vec();
let tree = inline_tree_sitter_parser.parse(region, None).unwrap();
let mut query_cursor = tree_sitter::QueryCursor::new();
let mut matches =
query_cursor.matches(code_span_query, tree.root_node(), region.as_bytes());
while let Some(query_match) = matches.next() {
let range = query_match
.captures
.first()
.expect("Empty Tree-sitter code_span query match")
.node
.byte_range();
blank_preserving_line_breaks(&mut view[range]);
}
String::from_utf8(view).expect("view is built from the valid-UTF-8 region")
}
}
impl CommentsParser for MdCommentsParser {
fn parse<'source>(
&'source mut self,
contents: &'source str,
) -> impl Iterator<Item = Comment> + 'source {
let mut comments = self.parse_comments(contents);
comments.sort_by_key(|comment| comment.source_range.start);
comments.into_iter()
}
}
fn link_reference_definition_comment_text(comment: &str) -> Option<String> {
let prefix_idx = comment.find("[//]:")?;
let start_search = prefix_idx + 5;
let open_idx = comment[start_search..]
.find(|c| ['(', '"', '\''].contains(&c))
.map(|i| i + start_search)
.expect("comment is expected to have a title delimiter");
let open_char = comment.chars().nth(open_idx).unwrap();
let close_char = match open_char {
'(' => ')',
'"' => '"',
'\'' => '\'',
_ => unreachable!(),
};
let close_idx = comment
.rfind(close_char)
.expect("comment is expected to end with matching delimiter");
let mut result = String::with_capacity(comment.len());
result.push_str(&comment[..prefix_idx]);
result.push_str(" ");
result.push_str(" ".repeat(open_idx - (prefix_idx + 5) + 1).as_str());
result.push_str(&comment[open_idx + 1..close_idx]);
result.push(' ');
if close_idx + 1 < comment.len() {
result.push_str(&comment[close_idx + 1..]);
}
Some(result)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::blocks::Block;
use crate::{Position, test_utils};
use std::collections::HashMap;
#[test]
fn parses_markdown_blocks_correctly() -> anyhow::Result<()> {
let mut parser = parser()?;
let content = r#"
# Header
[foo]: /url "title"
[//]: # (<block name="md_block">)
Some text here
[//]: # (</block>)
[//]: # (<block name="md_block_2">)
Some text here 2
[//]: # (<block name="md_block_3">)
Some text here 3
[//]: # (</block>)
[//]: # (</block>)
"#;
let blocks = parser.parse(content)?;
assert_eq!(
blocks,
vec![
Block::new(
HashMap::from([("name".to_string(), "md_block".to_string())]),
Position::new(5, 10)..=Position::new(5, 32),
test_utils::substr_range(content, "Some text here\n\n"),
Position::new(6, 1)..Position::new(8, 1),
),
Block::new(
HashMap::from([("name".to_string(), "md_block_2".to_string())]),
Position::new(10, 10)..=Position::new(10, 34),
test_utils::substr_range(
content,
"Some text here 2\n\n[//]: # (<block name=\"md_block_3\">)\nSome text here 3\n\n[//]: # (</block>)\n"
),
Position::new(11, 1)..Position::new(17, 1),
),
Block::new(
HashMap::from([("name".to_string(), "md_block_3".to_string())]),
Position::new(13, 10)..=Position::new(13, 34),
test_utils::substr_range(content, "Some text here 3\n\n"),
Position::new(14, 1)..Position::new(16, 1),
)
]
);
Ok(())
}
#[test]
fn parses_inline_html_comments_correctly() -> anyhow::Result<()> {
let mut parser = parser()?;
let content = r#"
# Header
Some text <!-- <block name="inline_block"> --> and
more content here
ending text <!-- </block> --> tail.
Inline code `<!-- <block name="ignored"> -->` is not a comment.
"#;
let blocks = parser.parse(content)?;
assert_eq!(
blocks,
vec![Block::new(
HashMap::from([("name".to_string(), "inline_block".to_string())]),
Position::new(4, 16)..=Position::new(4, 42),
test_utils::substr_range(content, " and\nmore content here\nending text "),
Position::new(4, 47)..Position::new(6, 13),
)]
);
Ok(())
}
#[test]
fn parses_blocks_spanning_block_level_and_inline_html_comments() -> anyhow::Result<()> {
let mut parser = parser()?;
let content = r#"
<!-- <block name="mixed"> -->
Some content.
Closing text <!-- </block> --> tail.
"#;
let blocks = parser.parse(content)?;
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].attributes["name"], "mixed");
Ok(())
}
#[test]
fn parses_blocks_spanning_link_reference_and_html_comment_syntaxes() -> anyhow::Result<()> {
let mut parser = parser()?;
let content = r#"
[//]: # (<block name="a">)
First block content.
<!-- </block> -->
<!-- <block name="b"> -->
Second block content.
[//]: # (</block>)
"#;
let blocks = parser.parse(content)?;
assert_eq!(blocks.len(), 2);
assert_eq!(blocks[0].attributes["name"], "a");
assert!(blocks[0].content(content).contains("First block content"));
assert_eq!(blocks[1].attributes["name"], "b");
assert!(blocks[1].content(content).contains("Second block content"));
Ok(())
}
#[test]
fn parses_html_blocks_correctly() -> anyhow::Result<()> {
let mut parser = parser()?;
let content = r#"
# Header
<div>
<!-- <block name="html_block"> -->
Some html content
<!-- </block> -->
</div>
[//]: # (<block name="md_block">)
Some markdown content
[//]: # (</block>)
<!-- <block name="html_block2"> -->Not wrapped in HTML tags<!-- </block> -->
<!-- <block name="html_block3"> -->
Not wrapped in HTML tags on multiple lines
<!-- </block> -->
"#;
let blocks = parser.parse(content)?;
assert_eq!(
blocks,
vec![
Block::new(
HashMap::from([("name".to_string(), "html_block".to_string())]),
Position::new(5, 6)..=Position::new(5, 30),
test_utils::substr_range(content, "\nSome html content\n"),
Position::new(5, 35)..Position::new(7, 1),
),
Block::new(
HashMap::from([("name".to_string(), "md_block".to_string())]),
Position::new(10, 10)..=Position::new(10, 32),
test_utils::substr_range(content, "Some markdown content\n\n"),
Position::new(11, 1)..Position::new(13, 1),
),
Block::new(
HashMap::from([("name".to_string(), "html_block2".to_string())]),
Position::new(15, 6)..=Position::new(15, 31),
test_utils::substr_range(content, "Not wrapped in HTML tags"),
Position::new(15, 36)..Position::new(15, 60),
),
Block::new(
HashMap::from([("name".to_string(), "html_block3".to_string())]),
Position::new(17, 6)..=Position::new(17, 31),
test_utils::substr_range(
content,
"\nNot wrapped in HTML tags on multiple lines\n"
),
Position::new(17, 36)..Position::new(19, 1),
),
]
);
Ok(())
}
}