Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
281 lines
9.8 KiB
Rust
281 lines
9.8 KiB
Rust
//! Lists go on as typed; lines shift in and out.
|
|
|
|
use std::ops::Range;
|
|
|
|
use gpui::{Context, Window};
|
|
use markdown::mdast;
|
|
|
|
use super::MarkdownDocument;
|
|
use crate::documents::markdown::{span, tree, walk};
|
|
|
|
/// Spaces one indent step adds.
|
|
const STEP: usize = 4;
|
|
|
|
/// Enter at `at`: the edit, none outside list items.
|
|
pub(super) fn enter(source: &str, at: usize) -> Option<(Range<usize>, String)> {
|
|
let root = tree(source);
|
|
let mut nodes = Vec::new();
|
|
walk(&root, &mut nodes);
|
|
let held: Vec<&mdast::Node> = nodes
|
|
.into_iter()
|
|
.filter(|node| span(node).start <= at && at <= span(node).end)
|
|
.collect();
|
|
let raw = |node: &&&mdast::Node| matches!(node, mdast::Node::Code(_) | mdast::Node::Math(_));
|
|
if held.iter().any(|node| raw(&node)) {
|
|
return None;
|
|
}
|
|
let item = held
|
|
.iter()
|
|
.filter(|node| matches!(node, mdast::Node::ListItem(_)))
|
|
.min_by_key(|node| span(node).len())?;
|
|
// The item may start at its indent; the marker follows.
|
|
let lead = &source[span(item).start..];
|
|
let start = span(item).start + lead.len() - lead.trim_start_matches([' ', '\t']).len();
|
|
let first = source[..start].rfind('\n').map_or(0, |found| found + 1);
|
|
// Quote marks stay; a parent's marker becomes spaces.
|
|
let container: String = source[first..start]
|
|
.chars()
|
|
.map(|ch| {
|
|
if ch == '>' || ch.is_whitespace() {
|
|
ch
|
|
} else {
|
|
' '
|
|
}
|
|
})
|
|
.collect();
|
|
let rest = &source[start..];
|
|
let (marker, after) = bullet(rest).or_else(|| ordered(rest))?;
|
|
let gap = rest[after..].len() - rest[after..].trim_start_matches([' ', '\t']).len();
|
|
let gap = match &rest[after..after + gap] {
|
|
"" => " ",
|
|
written => written,
|
|
};
|
|
let body = source[start + after..span(item).end].trim_start();
|
|
// A bare box has no `checked`: it is still a task.
|
|
let boxed = ["[ ]", "[x]", "[X]"]
|
|
.iter()
|
|
.any(|task| body.starts_with(task));
|
|
let task = boxed || matches!(item, mdast::Node::ListItem(item) if item.checked.is_some());
|
|
let body = match boxed {
|
|
true => &body[3..],
|
|
false => body,
|
|
};
|
|
let end = source[at..]
|
|
.find('\n')
|
|
.map_or(source.len(), |found| at + found);
|
|
if body.trim().is_empty() {
|
|
// An empty item ends the list.
|
|
return (at == end).then(|| (start..end, String::new()));
|
|
}
|
|
let task = if task { "[ ] " } else { "" };
|
|
Some((at..at, format!("\n{container}{marker}{gap}{task}")))
|
|
}
|
|
|
|
/// A bullet marker and its length.
|
|
fn bullet(rest: &str) -> Option<(String, usize)> {
|
|
let mark = rest.chars().next().filter(|ch| "-*+".contains(*ch))?;
|
|
Some((mark.to_string(), 1))
|
|
}
|
|
|
|
/// A number marker, counted on, and its length.
|
|
fn ordered(rest: &str) -> Option<(String, usize)> {
|
|
let digits = rest.chars().take_while(char::is_ascii_digit).count();
|
|
if !(1..=9).contains(&digits) {
|
|
return None;
|
|
}
|
|
let close = rest[digits..]
|
|
.chars()
|
|
.next()
|
|
.filter(|ch| ".)".contains(*ch))?;
|
|
let number: u64 = rest[..digits].parse().expect("ascii digits");
|
|
Some((format!("{}{close}", number + 1), digits + 1))
|
|
}
|
|
|
|
/// Lines `range` touches, a step in or out.
|
|
pub(super) fn shift(text: &str, range: Range<usize>, deeper: bool) -> (String, Range<usize>) {
|
|
let first = text[..range.start].rfind('\n').map_or(0, |at| at + 1);
|
|
let last = text[range.end..]
|
|
.find('\n')
|
|
.map_or(text.len(), |at| range.end + at);
|
|
// Each change: where, and bytes added or removed.
|
|
let mut changes: Vec<(usize, isize)> = Vec::new();
|
|
let mut at = first;
|
|
loop {
|
|
let line = &text[at..text[at..].find('\n').map_or(text.len(), |end| at + end)];
|
|
let spaces = line.len() - line.trim_start_matches(' ').len();
|
|
match deeper {
|
|
true => changes.push((at, STEP as isize)),
|
|
// A leading tab is one step out.
|
|
false if line.starts_with('\t') => changes.push((at, -1)),
|
|
false if spaces > 0 => changes.push((at, -(spaces.min(STEP) as isize))),
|
|
false => {}
|
|
}
|
|
match text[at..].find('\n') {
|
|
Some(end) if at + end < last => at += end + 1,
|
|
_ => break,
|
|
}
|
|
}
|
|
let mut out = String::with_capacity(text.len() + changes.len() * STEP);
|
|
let mut from = 0;
|
|
for (at, change) in &changes {
|
|
out.push_str(&text[from..*at]);
|
|
from = *at;
|
|
match *change > 0 {
|
|
true => out.push_str(&" ".repeat(STEP)),
|
|
false => from += change.unsigned_abs(),
|
|
}
|
|
}
|
|
out.push_str(&text[from..]);
|
|
let moved = |pos: usize| {
|
|
let mut shifted = pos as isize;
|
|
for (at, change) in &changes {
|
|
match *change > 0 {
|
|
true if *at <= pos => shifted += change,
|
|
false if *at < pos => shifted -= (pos - at).min(change.unsigned_abs()) as isize,
|
|
_ => {}
|
|
}
|
|
}
|
|
shifted as usize
|
|
};
|
|
(out, moved(range.start)..moved(range.end))
|
|
}
|
|
|
|
impl MarkdownDocument {
|
|
/// Enter on a list line in source; false leaves it.
|
|
pub(super) fn source_newline(&mut self, cx: &mut Context<Self>) -> bool {
|
|
let editor = self.editor.read(cx);
|
|
let caret = editor.primary().range();
|
|
if editor.selections().len() != 1 || !caret.is_empty() {
|
|
return false;
|
|
}
|
|
if !self.read_now(cx) {
|
|
self.reparse(true, cx);
|
|
}
|
|
if !self.read_now(cx) {
|
|
log::info!("markdown document: Enter while a long text is read");
|
|
return false;
|
|
}
|
|
let editor = self.editor.read(cx);
|
|
let text = editor.text();
|
|
let ix = self.block_at(caret.start);
|
|
let block = self.parsed.blocks.get(ix).map(|block| block.range.clone());
|
|
let Some(block) =
|
|
block.filter(|block| block.start <= caret.start && caret.start <= block.end)
|
|
else {
|
|
return false;
|
|
};
|
|
let at = caret.start - block.start;
|
|
let Some((range, typed)) = enter(&text[block.clone()], at) else {
|
|
return false;
|
|
};
|
|
let range = range.start + block.start..range.end + block.start;
|
|
let after = range.start + typed.len();
|
|
let edit = (range, typed);
|
|
log::info!("markdown document: a list goes on in source");
|
|
self.editor.update(cx, |editor, cx| {
|
|
editor.edit([edit], cx);
|
|
editor.select(std::iter::once(after..after), cx);
|
|
});
|
|
true
|
|
}
|
|
|
|
/// Enter on a block list line; false leaves it.
|
|
pub(super) fn block_newline(&mut self, cx: &mut Context<Self>) -> bool {
|
|
let Some(open) = &self.open else {
|
|
return false;
|
|
};
|
|
let input = open.input.clone();
|
|
let (text, caret) = {
|
|
let input = input.read(cx);
|
|
(input.text().to_string(), input.selection())
|
|
};
|
|
if !caret.is_empty() {
|
|
return false;
|
|
}
|
|
let Some((range, typed)) = enter(&text, caret.start) else {
|
|
return false;
|
|
};
|
|
log::info!("markdown document: a list goes on in a block");
|
|
input.update(cx, |input, cx| {
|
|
input.select(range, cx);
|
|
input.insert(&typed, cx);
|
|
});
|
|
true
|
|
}
|
|
|
|
/// Tab or Shift-Tab in the open block.
|
|
pub(super) fn block_shift(&mut self, deeper: bool, _: &mut Window, cx: &mut Context<Self>) {
|
|
let Some(open) = &self.open else {
|
|
return;
|
|
};
|
|
let input = open.input.clone();
|
|
let (text, selection) = {
|
|
let input = input.read(cx);
|
|
(input.text().to_string(), input.selection())
|
|
};
|
|
let (shifted, selection) = shift(&text, selection, deeper);
|
|
input.update(cx, |input, cx| {
|
|
input.set_text(shifted, cx);
|
|
input.select(selection, cx);
|
|
});
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{enter, shift};
|
|
|
|
/// Enter at the end of `source`'s line holding `at`.
|
|
fn entered(source: &str, at: &str) -> Option<String> {
|
|
let at = source.find(at).expect("in the text") + at.len();
|
|
let (range, typed) = enter(source, at)?;
|
|
Some(format!(
|
|
"{}{typed}{}",
|
|
&source[..range.start],
|
|
&source[range.end..]
|
|
))
|
|
}
|
|
|
|
#[test]
|
|
fn items_go_on_with_their_marker() {
|
|
assert_eq!(entered("- a", "a").as_deref(), Some("- a\n- "));
|
|
assert_eq!(entered(" * a", "a").as_deref(), Some(" * a\n * "));
|
|
assert_eq!(entered("9. a", "a").as_deref(), Some("9. a\n10. "));
|
|
assert_eq!(entered("3) a", "a").as_deref(), Some("3) a\n4) "));
|
|
assert_eq!(
|
|
entered("- [x] done", "done").as_deref(),
|
|
Some("- [x] done\n- [ ] ")
|
|
);
|
|
assert_eq!(entered("- a\n- ", "- a\n- ").as_deref(), Some("- a\n"));
|
|
let plain = [
|
|
("text", "text"),
|
|
("-a", "-a"),
|
|
("* * *", "* * *"),
|
|
("```\n- a\n```", "- a"),
|
|
("1.a", "1.a"),
|
|
];
|
|
for (plain, at) in plain {
|
|
assert_eq!(entered(plain, at), None, "{plain:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn quotes_tabs_and_nesting_carry_on() {
|
|
assert_eq!(entered("> - a", "a").as_deref(), Some("> - a\n> - "));
|
|
assert_eq!(entered("-\ta", "a").as_deref(), Some("-\ta\n-\t"));
|
|
assert_eq!(
|
|
entered("- a\n\t- b", "b").as_deref(),
|
|
Some("- a\n\t- b\n\t- ")
|
|
);
|
|
assert_eq!(entered("- - a", "a").as_deref(), Some("- - a\n - "));
|
|
}
|
|
|
|
#[test]
|
|
fn lines_shift_in_and_out_with_the_selection() {
|
|
assert_eq!(shift("a\nb", 0..0, true), (" a\nb".into(), 4..4));
|
|
assert_eq!(shift("a\nb", 1..3, true), (" a\n b".into(), 5..11));
|
|
assert_eq!(shift(" a", 7..7, false), (" a".into(), 3..3));
|
|
assert_eq!(shift(" a\nb", 1..1, false), ("a\nb".into(), 0..0));
|
|
}
|
|
}
|