Files
Ely-GPUI-Components/src/documents/document/lists.rs
T
ZacharyZhang-NYandClaude Opus 5.5 4fb59bb8e1
CI / platforms (windows-latest) (push) Failing after 8s
CI / site (push) Successful in 22s
CI / crate (push) Failing after 11m41s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
documents: an empty task ends a list; Shift-Tab takes a tab out; a table on a blank indented line is a table
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 07:17:24 -04:00

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));
}
}