use crate::syntax::{AstNode, List, ListKind, SyntaxKind, SyntaxNode, SyntaxToken};
use tower_lsp_server::ls_types::{Range, TextEdit};
use super::super::conversions::offset_to_position;
pub fn find_list_at_position(tree: &SyntaxNode, offset: usize) -> Option<SyntaxNode> {
let text_size = rowan::TextSize::from(offset as u32);
let token = tree.token_at_offset(text_size).right_biased()?;
token
.parent_ancestors()
.find(|node| node.kind() == SyntaxKind::LIST)
}
pub fn detect_list_type(list_node: &SyntaxNode) -> Option<ListKind> {
let list = List::cast(list_node.clone())?;
list.kind()
}
pub fn convert_to_loose(list_node: &SyntaxNode, text: &str) -> Vec<TextEdit> {
let list = match List::cast(list_node.clone()) {
Some(l) => l,
None => return vec![],
};
if list.is_loose() {
return vec![];
}
let mut edits = Vec::new();
let items: Vec<_> = list.items().collect();
for (idx, item) in items.iter().enumerate() {
if idx == items.len() - 1 {
continue;
}
let item_end = item.syntax().text_range().end().into();
let position = offset_to_position(text, item_end);
edits.push(TextEdit {
range: Range {
start: position,
end: position,
},
new_text: "\n".to_string(),
});
}
edits
}
pub fn convert_to_compact(list_node: &SyntaxNode, text: &str) -> Vec<TextEdit> {
let list = match List::cast(list_node.clone()) {
Some(l) => l,
None => return vec![],
};
if list.is_compact() {
return vec![];
}
let mut edits = Vec::new();
let children: Vec<_> = list_node.children_with_tokens().collect();
let mut prev_was_item = false;
for (idx, child) in children.iter().enumerate() {
if let Some(node) = child.as_node() {
if node.kind() == SyntaxKind::LIST_ITEM {
prev_was_item = true;
} else if node.kind() == SyntaxKind::BLANK_LINE && prev_was_item {
let has_next_item = children[idx + 1..]
.iter()
.find(|c| {
c.as_node()
.map(|n| n.kind() != SyntaxKind::BLANK_LINE)
.unwrap_or(false)
})
.and_then(|c| c.as_node())
.map(|n| n.kind() == SyntaxKind::LIST_ITEM)
.unwrap_or(false);
if has_next_item {
let start = offset_to_position(text, node.text_range().start().into());
let end = offset_to_position(text, node.text_range().end().into());
edits.push(TextEdit {
range: Range { start, end },
new_text: String::new(),
});
}
}
} else if let Some(token) = child.as_token()
&& token.kind() == SyntaxKind::BLANK_LINE
&& prev_was_item
{
let has_next_item = children[idx + 1..]
.iter()
.find(|c| {
if let Some(n) = c.as_node() {
n.kind() != SyntaxKind::BLANK_LINE
} else if let Some(t) = c.as_token() {
t.kind() != SyntaxKind::BLANK_LINE
} else {
false
}
})
.and_then(|c| c.as_node())
.map(|n| n.kind() == SyntaxKind::LIST_ITEM)
.unwrap_or(false);
if has_next_item {
let start = offset_to_position(text, token.text_range().start().into());
let end = offset_to_position(text, token.text_range().end().into());
edits.push(TextEdit {
range: Range { start, end },
new_text: String::new(),
});
}
}
}
edits
}
pub fn convert_to_ordered(list_node: &SyntaxNode, text: &str) -> Vec<TextEdit> {
if detect_list_type(list_node) != Some(ListKind::Bullet) {
return vec![];
}
let Some(list) = List::cast(list_node.clone()) else {
return vec![];
};
list.items()
.enumerate()
.filter_map(|(idx, item)| {
let marker = first_item_marker_token(item.syntax())?;
let start = offset_to_position(text, marker.text_range().start().into());
let end = offset_to_position(text, marker.text_range().end().into());
Some(TextEdit {
range: Range { start, end },
new_text: format!("{}.", idx + 1),
})
})
.collect()
}
pub fn convert_to_bullet(list_node: &SyntaxNode, text: &str) -> Vec<TextEdit> {
if !matches!(
detect_list_type(list_node),
Some(ListKind::Ordered | ListKind::Task)
) {
return vec![];
}
let Some(list) = List::cast(list_node.clone()) else {
return vec![];
};
list.items()
.flat_map(|item| {
let mut edits = Vec::new();
let Some(marker) = first_item_marker_token(item.syntax()) else {
return edits;
};
let start = offset_to_position(text, marker.text_range().start().into());
let end = offset_to_position(text, marker.text_range().end().into());
edits.push(TextEdit {
range: Range { start, end },
new_text: "-".to_string(),
});
if let Some(checkbox) = task_checkbox_token(item.syntax()) {
let checkbox_start = offset_to_position(text, checkbox.text_range().start().into());
let checkbox_end = offset_to_position(
text,
checkbox_removal_end_offset(text, checkbox.text_range().end().into()),
);
edits.push(TextEdit {
range: Range {
start: checkbox_start,
end: checkbox_end,
},
new_text: String::new(),
});
}
edits
})
.collect()
}
pub fn convert_to_task(list_node: &SyntaxNode, text: &str) -> Vec<TextEdit> {
let list_type = detect_list_type(list_node);
if !matches!(list_type, Some(ListKind::Bullet | ListKind::Ordered)) {
return vec![];
}
let Some(list) = List::cast(list_node.clone()) else {
return vec![];
};
list.items()
.flat_map(|item| {
let mut edits = Vec::new();
let marker = first_item_marker_token(item.syntax())?;
if list_type == Some(ListKind::Ordered) {
let marker_start = offset_to_position(text, marker.text_range().start().into());
let marker_end = offset_to_position(text, marker.text_range().end().into());
edits.push(TextEdit {
range: Range {
start: marker_start,
end: marker_end,
},
new_text: "-".to_string(),
});
}
let insert_at = offset_to_position(text, marker.text_range().end().into());
edits.push(TextEdit {
range: Range {
start: insert_at,
end: insert_at,
},
new_text: " [ ]".to_string(),
});
Some(edits)
})
.flatten()
.collect()
}
pub fn convert_task_to_ordered(list_node: &SyntaxNode, text: &str) -> Vec<TextEdit> {
if detect_list_type(list_node) != Some(ListKind::Task) {
return vec![];
}
let Some(list) = List::cast(list_node.clone()) else {
return vec![];
};
list.items()
.enumerate()
.flat_map(|(idx, item)| {
let mut edits = Vec::new();
let Some(marker) = first_item_marker_token(item.syntax()) else {
return edits;
};
let marker_start = offset_to_position(text, marker.text_range().start().into());
let marker_end = offset_to_position(text, marker.text_range().end().into());
edits.push(TextEdit {
range: Range {
start: marker_start,
end: marker_end,
},
new_text: format!("{}.", idx + 1),
});
if let Some(checkbox) = task_checkbox_token(item.syntax()) {
let checkbox_start = offset_to_position(text, checkbox.text_range().start().into());
let checkbox_end = offset_to_position(
text,
checkbox_removal_end_offset(text, checkbox.text_range().end().into()),
);
edits.push(TextEdit {
range: Range {
start: checkbox_start,
end: checkbox_end,
},
new_text: String::new(),
});
}
edits
})
.collect()
}
fn first_item_marker_token(item_node: &SyntaxNode) -> Option<SyntaxToken> {
item_node
.children_with_tokens()
.find_map(|elem| elem.as_token().cloned())
.filter(|token| token.kind() == SyntaxKind::LIST_MARKER)
}
fn task_checkbox_token(item_node: &SyntaxNode) -> Option<SyntaxToken> {
item_node.children_with_tokens().find_map(|elem| {
elem.as_token()
.filter(|token| token.kind() == SyntaxKind::TASK_CHECKBOX)
.cloned()
})
}
fn checkbox_removal_end_offset(text: &str, checkbox_end: usize) -> usize {
match text.as_bytes().get(checkbox_end) {
Some(b' ' | b'\t') => checkbox_end + 1,
_ => checkbox_end,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parse;
use tower_lsp_server::ls_types::Position;
fn apply_text_edits(input: &str, edits: &[TextEdit]) -> String {
fn position_to_offset(text: &str, position: Position) -> usize {
let target_line = position.line as usize;
let target_char = position.character as usize;
let mut line = 0usize;
let mut col = 0usize;
for (idx, ch) in text.char_indices() {
if line == target_line && col == target_char {
return idx;
}
if ch == '\n' {
line += 1;
col = 0;
} else {
col += 1;
}
}
text.len()
}
let mut result = input.to_string();
let mut sorted = edits.to_vec();
sorted.sort_by(|a, b| {
let key_a = (
a.range.start.line,
a.range.start.character,
a.range.end.line,
a.range.end.character,
);
let key_b = (
b.range.start.line,
b.range.start.character,
b.range.end.line,
b.range.end.character,
);
key_b.cmp(&key_a)
});
for edit in sorted {
let start = position_to_offset(&result, edit.range.start);
let end = position_to_offset(&result, edit.range.end);
result.replace_range(start..end, &edit.new_text);
}
result
}
#[test]
fn find_list_at_position_finds_innermost() {
let input = "- Outer\n - Inner\n - Inner2\n- Outer2\n";
let tree = parse(input, None);
let offset = input.find("Inner").unwrap();
let list = find_list_at_position(&tree, offset).expect("Should find list");
let wrapped = List::cast(list).expect("Should cast to List");
assert_eq!(wrapped.items().count(), 2);
}
#[test]
fn convert_compact_to_loose() {
let input = "- First\n- Second\n- Third\n";
let tree = parse(input, None);
let list_node = tree
.descendants()
.find(|n| n.kind() == SyntaxKind::LIST)
.expect("Should find list");
let edits = convert_to_loose(&list_node, input);
assert_eq!(edits.len(), 2);
assert_eq!(edits[0].new_text, "\n");
assert_eq!(edits[0].range.start.line, 1);
assert_eq!(edits[1].new_text, "\n");
assert_eq!(edits[1].range.start.line, 2);
}
#[test]
fn convert_loose_to_compact() {
let input = "- First\n\n- Second\n\n- Third\n";
let tree = parse(input, None);
let list_node = tree
.descendants()
.find(|n| n.kind() == SyntaxKind::LIST)
.expect("Should find list");
let edits = convert_to_compact(&list_node, input);
assert_eq!(edits.len(), 2);
for edit in &edits {
assert_eq!(edit.new_text, "");
assert!(edit.range.start != edit.range.end);
}
}
#[test]
fn already_loose_returns_empty() {
let input = "- First\n\n- Second\n";
let tree = parse(input, None);
let list_node = tree
.descendants()
.find(|n| n.kind() == SyntaxKind::LIST)
.expect("Should find list");
let edits = convert_to_loose(&list_node, input);
assert_eq!(edits.len(), 0, "Should return no edits for already loose");
}
#[test]
fn already_compact_returns_empty() {
let input = "- First\n- Second\n";
let tree = parse(input, None);
let list_node = tree
.descendants()
.find(|n| n.kind() == SyntaxKind::LIST)
.expect("Should find list");
let edits = convert_to_compact(&list_node, input);
assert_eq!(edits.len(), 0, "Should return no edits for already compact");
}
#[test]
fn detect_list_type_for_bullet_and_ordered() {
let bullet_tree = parse("- First\n- Second\n", None);
let bullet_list = bullet_tree
.descendants()
.find(|n| n.kind() == SyntaxKind::LIST)
.expect("Should find bullet list");
assert_eq!(detect_list_type(&bullet_list), Some(ListKind::Bullet));
let ordered_tree = parse("1. First\n2. Second\n", None);
let ordered_list = ordered_tree
.descendants()
.find(|n| n.kind() == SyntaxKind::LIST)
.expect("Should find ordered list");
assert_eq!(detect_list_type(&ordered_list), Some(ListKind::Ordered));
}
#[test]
fn detect_list_type_for_task() {
let task_tree = parse("- [ ] First\n- [x] Second\n", None);
let task_list = task_tree
.descendants()
.find(|n| n.kind() == SyntaxKind::LIST)
.expect("Should find task list");
assert_eq!(detect_list_type(&task_list), Some(ListKind::Task));
}
#[test]
fn convert_bullet_to_ordered() {
let input = "- First\n- Second\n- Third\n";
let tree = parse(input, None);
let list_node = tree
.descendants()
.find(|n| n.kind() == SyntaxKind::LIST)
.expect("Should find list");
let edits = convert_to_ordered(&list_node, input);
assert_eq!(edits.len(), 3);
assert_eq!(edits[0].new_text, "1.");
assert_eq!(edits[1].new_text, "2.");
assert_eq!(edits[2].new_text, "3.");
}
#[test]
fn convert_ordered_to_bullet() {
let input = "1. First\n2. Second\n3. Third\n";
let tree = parse(input, None);
let list_node = tree
.descendants()
.find(|n| n.kind() == SyntaxKind::LIST)
.expect("Should find list");
let edits = convert_to_bullet(&list_node, input);
assert_eq!(edits.len(), 3);
assert!(edits.iter().all(|edit| edit.new_text == "-"));
}
#[test]
fn convert_bullet_to_task() {
let input = "- First\n- Second\n";
let tree = parse(input, None);
let list_node = tree
.descendants()
.find(|n| n.kind() == SyntaxKind::LIST)
.expect("Should find list");
let edits = convert_to_task(&list_node, input);
assert_eq!(edits.len(), 2);
assert!(edits.iter().all(|edit| edit.new_text == " [ ]"));
}
#[test]
fn convert_ordered_to_task() {
let input = "1. First\n2. Second\n";
let tree = parse(input, None);
let list_node = tree
.descendants()
.find(|n| n.kind() == SyntaxKind::LIST)
.expect("Should find list");
let edits = convert_to_task(&list_node, input);
assert_eq!(edits.len(), 4, "marker + checkbox edit per item");
assert_eq!(edits[0].new_text, "-");
assert_eq!(edits[1].new_text, " [ ]");
assert_eq!(edits[2].new_text, "-");
assert_eq!(edits[3].new_text, " [ ]");
}
#[test]
fn convert_task_to_bullet_removes_checkboxes() {
let input = "- [ ] First\n- [x] Second\n";
let tree = parse(input, None);
let list_node = tree
.descendants()
.find(|n| n.kind() == SyntaxKind::LIST)
.expect("Should find list");
let edits = convert_to_bullet(&list_node, input);
assert_eq!(edits.len(), 4, "marker + checkbox edit per item");
assert_eq!(edits[0].new_text, "-");
assert_eq!(edits[1].new_text, "");
assert_eq!(edits[2].new_text, "-");
assert_eq!(edits[3].new_text, "");
let output = apply_text_edits(input, &edits);
assert_eq!(output, "- First\n- Second\n");
}
#[test]
fn convert_task_to_ordered_removes_checkboxes() {
let input = "- [ ] First\n- [x] Second\n";
let tree = parse(input, None);
let list_node = tree
.descendants()
.find(|n| n.kind() == SyntaxKind::LIST)
.expect("Should find list");
let edits = convert_task_to_ordered(&list_node, input);
assert_eq!(edits.len(), 4, "marker + checkbox edit per item");
assert_eq!(edits[0].new_text, "1.");
assert_eq!(edits[1].new_text, "");
assert_eq!(edits[2].new_text, "2.");
assert_eq!(edits[3].new_text, "");
let output = apply_text_edits(input, &edits);
assert_eq!(output, "1. First\n2. Second\n");
}
}