use std::ops::Range;
use crate::parser::{Grammar, parse};
use tree_sitter::Node;
use crate::{
config::Config,
spacing::{TextEdit, spacing_edits},
};
const EXCLUDED_NODE_KINDS: &[&str] = &[
"code_span",
"link_destination",
"link_title",
"link_label",
"uri_autolink",
"email_autolink",
"html_tag",
"latex_block",
"entity_reference",
"numeric_character_reference",
"backslash_escape",
];
pub(crate) fn plan_edits(config: &Config, source: &str) -> anyhow::Result<Vec<TextEdit>> {
let block_tree = parse(Grammar::Markdown, source)?;
let mut inline_ranges = Vec::new();
collect_inline_ranges(block_tree.root_node(), &mut inline_ranges);
let mut edits = Vec::new();
for inline_range in inline_ranges {
let inline_source = source
.get(inline_range.clone())
.ok_or_else(|| anyhow::anyhow!("Markdown inline node has an invalid byte range"))?;
let inline_tree = parse(Grammar::MarkdownInline, inline_source)?;
if inline_tree.root_node().has_error()
|| !is_safe_inline_tree(inline_tree.root_node(), inline_source)
{
continue;
}
let mut exclusions = Vec::new();
collect_exclusion_ranges(inline_tree.root_node(), &mut exclusions);
collect_unrecognized_autolink_ranges(
inline_tree.root_node(),
inline_source,
&mut exclusions,
);
merge_ranges(&mut exclusions);
for edit in spacing_edits(config, inline_source) {
if !exclusions
.iter()
.any(|exclusion| edit_intersects(&edit.range, exclusion))
{
edits.push(TextEdit {
range: (inline_range.start + edit.range.start)
..(inline_range.start + edit.range.end),
replacement: edit.replacement,
});
}
}
}
validate_edits(source, &mut edits)?;
Ok(edits)
}
fn collect_inline_ranges(node: Node<'_>, ranges: &mut Vec<Range<usize>>) {
if node.kind() == "inline" {
ranges.push(node.byte_range());
return;
}
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
collect_inline_ranges(child, ranges);
}
}
fn is_safe_inline_tree(root: Node<'_>, source: &str) -> bool {
let backtick_count = source.chars().filter(|&character| character == '`').count();
if backtick_count > 0 && (backtick_count % 2 == 1 || !has_node_kind(root, "code_span")) {
return false;
}
let dollar_count = source.chars().filter(|&character| character == '$').count();
if dollar_count % 2 == 1 {
return false;
}
if source.contains("](") && !has_node_kind(root, "inline_link") && !has_node_kind(root, "image")
{
return false;
}
true
}
fn collect_unrecognized_autolink_ranges(
root: Node<'_>,
source: &str,
ranges: &mut Vec<Range<usize>>,
) {
let mut search_start = 0;
while let Some(relative_start) = source[search_start..].find('<') {
let start = search_start + relative_start;
let Some(relative_end) = source[start + 1..].find('>') else {
break;
};
let end = start + 1 + relative_end + 1;
let candidate = &source[start..end];
if (candidate.contains('@') || candidate.contains("://"))
&& !has_exclusion_covering(root, start..end)
{
ranges.push(start..end);
}
search_start = end;
}
}
fn has_exclusion_covering(node: Node<'_>, range: Range<usize>) -> bool {
if EXCLUDED_NODE_KINDS.contains(&node.kind()) {
let node_range = node.byte_range();
if node_range.start <= range.start && range.end <= node_range.end {
return true;
}
}
let mut cursor = node.walk();
node.named_children(&mut cursor)
.any(|child| has_exclusion_covering(child, range.clone()))
}
fn has_node_kind(node: Node<'_>, kind: &str) -> bool {
if node.kind() == kind {
return true;
}
let mut cursor = node.walk();
node.named_children(&mut cursor)
.any(|child| has_node_kind(child, kind))
}
fn collect_exclusion_ranges(node: Node<'_>, ranges: &mut Vec<Range<usize>>) {
if EXCLUDED_NODE_KINDS.contains(&node.kind()) {
ranges.push(node.byte_range());
return;
}
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
collect_exclusion_ranges(child, ranges);
}
}
fn merge_ranges(ranges: &mut Vec<Range<usize>>) {
ranges.sort_by_key(|range| (range.start, range.end));
let mut merged: Vec<Range<usize>> = Vec::with_capacity(ranges.len());
for range in ranges.drain(..) {
if let Some(last) = merged.last_mut()
&& range.start <= last.end
{
last.end = last.end.max(range.end);
} else {
merged.push(range);
}
}
*ranges = merged;
}
fn edit_intersects(edit: &Range<usize>, exclusion: &Range<usize>) -> bool {
if edit.is_empty() {
exclusion.start <= edit.start && edit.start < exclusion.end
} else {
edit.start < exclusion.end && exclusion.start < edit.end
}
}
fn validate_edits(source: &str, edits: &mut [TextEdit]) -> anyhow::Result<()> {
edits.sort_by_key(|edit| (edit.range.start, edit.range.end));
for edit in edits.iter() {
if edit.range.start > edit.range.end
|| edit.range.end > source.len()
|| !source.is_char_boundary(edit.range.start)
|| !source.is_char_boundary(edit.range.end)
{
anyhow::bail!("spacing edit is not a valid UTF-8 range: {:?}", edit.range);
}
}
for pair in edits.windows(2) {
let previous = &pair[0].range;
let current = &pair[1].range;
if previous.end > current.start
|| (previous.is_empty() && current.is_empty() && previous.start == current.start)
|| (previous.start == current.start && (!previous.is_empty() || !current.is_empty()))
{
anyhow::bail!(
"overlapping spacing edits: {:?} and {:?}",
previous,
current
);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::SpacingRule;
fn config(alphabets: SpacingRule, digits: SpacingRule) -> Config {
let mut config = Config {
max_width: 200,
..Config::default()
};
config.spacing.alphabets = alphabets;
config.spacing.digits = digits;
config
}
fn format(source: &str, alphabets: SpacingRule, digits: SpacingRule) -> String {
let mut formatted = source.to_string();
let edits = plan_edits(&config(alphabets, digits), source).unwrap();
for edit in edits.into_iter().rev() {
formatted.replace_range(edit.range, &edit.replacement);
}
formatted
}
#[test]
fn formats_prose_inside_inline_markdown_constructs() {
assert_eq!(
format(
"*漢A* **漢1** ~~A漢~~",
SpacingRule::Require,
SpacingRule::Require
),
"*漢 A* **漢 1** ~~A 漢~~"
);
}
#[test]
fn formats_link_text_and_image_description_but_not_destinations() {
let source = "[漢A](https://example.test/漢A) ";
assert_eq!(
format(source, SpacingRule::Require, SpacingRule::Ignore),
"[漢 A](https://example.test/漢A) "
);
}
#[test]
fn preserves_non_prose_inline_ranges() {
let source = concat!(
"`漢A` ``漢1`` <https://example.test/漢A> <foo@example.test> ",
"<漢A@example.test> <a href=\"漢A\"> $漢A$ &漢A \\*漢A\n",
);
assert_eq!(
format(source, SpacingRule::Require, SpacingRule::Require),
concat!(
"`漢A` ``漢1`` <https://example.test/漢A> <foo@example.test> ",
"<漢A@example.test> <a href=\"漢A\"> $漢A$ &漢 A \\*漢 A\n",
)
);
}
#[test]
fn reference_link_label_is_not_formatted() {
assert_eq!(
format("[漢A][漢A]", SpacingRule::Require, SpacingRule::Ignore),
"[漢 A][漢A]"
);
}
#[test]
fn preserves_fenced_code_and_formats_other_inline_nodes() {
let source = "漢A\n\n```rust 漢A\n漢A\n```\n\n漢A\n";
assert_eq!(
format(source, SpacingRule::Require, SpacingRule::Ignore),
"漢 A\n\n```rust 漢A\n漢A\n```\n\n漢 A\n"
);
}
#[test]
fn applies_many_document_edits_using_original_offsets() {
let source = "漢A\n漢A\n漢A";
assert_eq!(
format(source, SpacingRule::Require, SpacingRule::Ignore),
"漢 A\n漢 A\n漢 A"
);
}
#[test]
fn prohibit_removes_only_ascii_spaces_in_prose() {
assert_eq!(
format("漢 A `漢 A`", SpacingRule::Prohibit, SpacingRule::Ignore),
"漢A `漢 A`"
);
}
#[test]
fn malformed_inline_recovery_is_kept_unchanged() {
for source in ["[漢A](<broken\n漢A>)", "[漢A](broken", "`漢A", "``漢A`"] {
assert_eq!(
format(source, SpacingRule::Require, SpacingRule::Ignore),
source,
"malformed inline source was changed: {source:?}"
);
}
}
#[test]
fn returns_document_edits_in_ascending_source_order() {
let edits = plan_edits(
&config(SpacingRule::Require, SpacingRule::Require),
"漢A\n漢1\nA漢",
)
.unwrap();
assert_eq!(
edits
.iter()
.map(|edit| edit.range.clone())
.collect::<Vec<_>>(),
vec![3..3, 8..8, 11..11]
);
}
#[test]
fn rejects_invalid_and_overlapping_edits() {
let mut invalid = vec![TextEdit {
range: 1..2,
replacement: String::new(),
}];
assert!(validate_edits("漢", &mut invalid).is_err());
let mut overlapping = vec![
TextEdit {
range: 0..1,
replacement: String::new(),
},
TextEdit {
range: 0..0,
replacement: " ".to_string(),
},
];
assert!(validate_edits("漢", &mut overlapping).is_err());
}
#[test]
fn validates_and_applies_edits_in_reverse_order() {
let source = "漢A漢A";
let mut edits = vec![
TextEdit {
range: 3..3,
replacement: " ".to_string(),
},
TextEdit {
range: 7..7,
replacement: " ".to_string(),
},
];
validate_edits(source, &mut edits).unwrap();
let mut formatted = source.to_string();
for edit in edits.into_iter().rev() {
formatted.replace_range(edit.range, &edit.replacement);
}
assert_eq!(formatted, "漢 A漢 A");
}
}