5e8020aa38
Editor previews (colour swatches, arrows, embeds), the 18 obsidian-basics tasks (find and replace with regex, back/forward, auto-pair, footnotes, folding, search operators, split view, PDF/audio/ video embeds, spelling, drag and drop, pinned tabs, settings) and round 2: ```query blocks, stacked tabs, pop-out windows, tasks ticked inside embeds, text around an embed, embeds that follow edits, block menu shortcut. New crates: notescraft-regex (linear-time engine) and notescraft-syntax. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
212 lines
7.6 KiB
Rust
212 lines
7.6 KiB
Rust
//! Tables for auto-pairing: pair and suppress rules, wrap the selection,
|
||
//! step over closers, and Backspace inside an empty pair. Positions come
|
||
//! from `find`/`len`, never hand-counted; `|` marks the caret and `‹…›` a
|
||
//! selection in every row, so byte offsets are never written by hand.
|
||
|
||
use notescraft_editor::keys::{self, Edit};
|
||
use notescraft_editor::pairs;
|
||
|
||
fn applied(text: &str, edit: &Edit) -> String {
|
||
let mut out = String::from(text);
|
||
out.replace_range(edit.range.clone(), &edit.insert);
|
||
out
|
||
}
|
||
|
||
/// Split a marked string into (text, selection): `|` = empty caret, `‹…›` =
|
||
/// selection. Offsets come from `find`/`len`, never hand-counted.
|
||
fn parse(marked: &str) -> (String, std::ops::Range<usize>) {
|
||
if let Some((before, mid, after)) = marked.split_once('‹').and_then(|(b, rest)| rest.split_once('›').map(|(mid, after)| (b, mid, after))) {
|
||
let start = before.len();
|
||
return (format!("{before}{mid}{after}"), start..start + mid.len());
|
||
}
|
||
let at = marked.find('|').unwrap_or(marked.len());
|
||
(marked.replace('|', ""), at..at)
|
||
}
|
||
|
||
/// One `auto_close` expectation: the edit's applied text and new selection
|
||
/// must match the marked expectation, or both must be `None`.
|
||
fn check_close(marked: &str, typed: char, expected: Option<&str>) {
|
||
let (text, sel) = parse(marked);
|
||
let edit = keys::auto_close(&text, sel, typed);
|
||
assert_eq!(edit.is_some(), expected.is_some(), "input {marked:?} + {typed}");
|
||
if let (Some(edit), Some(exp)) = (edit, expected) {
|
||
let (want, want_sel) = parse(exp);
|
||
assert_eq!(applied(&text, &edit), want, "input {marked:?} + {typed}");
|
||
assert_eq!(edit.sel, want_sel, "input {marked:?} + {typed}");
|
||
}
|
||
}
|
||
|
||
/// One `on_backspace` expectation in the same marked format.
|
||
fn check_backspace(marked: &str, expected: Option<&str>) {
|
||
let (text, sel) = parse(marked);
|
||
let edit = pairs::on_backspace(&text, sel);
|
||
assert_eq!(edit.is_some(), expected.is_some(), "input {marked:?}");
|
||
if let (Some(edit), Some(exp)) = (edit, expected) {
|
||
let (want, want_sel) = parse(exp);
|
||
assert_eq!(applied(&text, &edit), want, "input {marked:?}");
|
||
assert_eq!(edit.sel, want_sel, "input {marked:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn pair_openers_and_suppression_rules() {
|
||
let rows: &[(&str, char, Option<&str>)] = &[
|
||
("|", '(', Some("(|)")),
|
||
("|", '[', Some("[|]")),
|
||
("|", '{', Some("{|}")),
|
||
("|", '"', Some("\"|\"")),
|
||
("|", '`', Some("`|`")),
|
||
("|", '\'', None),
|
||
("ab|c", '(', None),
|
||
("ab|c", '[', None),
|
||
("ab|c", '{', None),
|
||
("don|t", '"', None),
|
||
("it|s", '\'', None),
|
||
("a |b", '"', Some("a \"|\"b")),
|
||
("a| b", '(', Some("a(|) b")),
|
||
("a| b", '[', Some("a[|] b")),
|
||
("a| b", '{', Some("a{|} b")),
|
||
("a(|)", ')', Some("a()|")),
|
||
("a[|]", ']', Some("a[]|")),
|
||
("a{|}", '}', Some("a{}|")),
|
||
("a\"|\"", '"', Some("a\"\"|")),
|
||
("a`|`", '`', Some("a``|")),
|
||
("a*|*", '*', Some("a**|")),
|
||
("a|", '*', None),
|
||
("a*|", '*', Some("a**|**")),
|
||
("(|)", '*', None),
|
||
("(|)", '\'', None),
|
||
("[|", '[', Some("[[|]")),
|
||
("x[|]", '[', Some("x[[|]]")),
|
||
("é|", '(', Some("é(|)")),
|
||
("éx|", '(', Some("éx(|)")),
|
||
("a😀|", '{', Some("a😀{|}")),
|
||
("é|x", '"', None),
|
||
("don|", '`', None),
|
||
("a |", '"', Some("a \"|\"")),
|
||
("word|", '"', None),
|
||
("foo_|", '"', None),
|
||
("word_|", '(', Some("word_(|)")),
|
||
("|word", '(', None),
|
||
("don\"|\"", '"', Some("don\"\"|")),
|
||
];
|
||
for (marked, typed, expected) in rows {
|
||
check_close(marked, *typed, *expected);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn selection_wraps_and_stays_selected() {
|
||
let rows: &[(&str, char, Option<&str>)] = &[
|
||
("say ‹hi›", '[', Some("say [‹hi›]")),
|
||
("say ‹hi›", '(', Some("say (‹hi›)")),
|
||
("say ‹hi›", '{', Some("say {‹hi›}")),
|
||
("say ‹hi›", '"', Some("say \"‹hi›\"")),
|
||
("say ‹hi›", '`', Some("say `‹hi›`")),
|
||
("say ‹hi›", '*', None),
|
||
("say ‹hi›", '_', None),
|
||
("say ‹hi›", '~', None),
|
||
("say ‹hi›", '=', None),
|
||
("say ‹hi›", '\'', None),
|
||
("‹é😀›", '(', Some("(‹é😀›)")),
|
||
("a‹bc›d", '[', Some("a[‹bc›]d")),
|
||
];
|
||
for (marked, typed, expected) in rows {
|
||
check_close(marked, *typed, *expected);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn wrap_selection_range_is_the_inner_text() {
|
||
let text = "say hi";
|
||
let sel = text.find("hi").unwrap()..text.len();
|
||
let edit = keys::auto_close(text, sel, '[').unwrap();
|
||
let new_text = applied(text, &edit);
|
||
assert_eq!(new_text, "say [hi]");
|
||
let open = new_text.find('[').unwrap();
|
||
let close = new_text.find(']').unwrap();
|
||
assert_eq!(edit.sel, open + 1..close);
|
||
}
|
||
|
||
#[test]
|
||
fn wrap_multibyte_selection_keeps_char_boundaries() {
|
||
let text = "é😀x";
|
||
let sel = text.find('😀').unwrap()..text.find('x').unwrap();
|
||
let edit = keys::auto_close(text, sel, '(').unwrap();
|
||
let new_text = applied(text, &edit);
|
||
assert_eq!(new_text, "é(😀)x");
|
||
let open = new_text.find('(').unwrap();
|
||
let close = new_text.find(')').unwrap();
|
||
assert_eq!(edit.sel, open + 1..close);
|
||
}
|
||
|
||
#[test]
|
||
fn backspace_deletes_empty_pairs() {
|
||
let rows: &[(&str, Option<&str>)] = &[
|
||
("(|)", Some("|")),
|
||
("[|]", Some("|")),
|
||
("{|}", Some("|")),
|
||
("\"|\"", Some("|")),
|
||
("`|`", Some("|")),
|
||
("a(|)b", Some("a|b")),
|
||
("a[|]b", Some("a|b")),
|
||
("a{|}b", Some("a|b")),
|
||
("(\"|\")", Some("(|)")),
|
||
("x[[|]]", Some("x[|]")),
|
||
("é(|)", Some("é|")),
|
||
("😀(|)", Some("😀|")),
|
||
("'|'", None),
|
||
("**|**", None),
|
||
("(|a", None),
|
||
("a(|", None),
|
||
("ab|", None),
|
||
];
|
||
for (marked, expected) in rows {
|
||
check_backspace(marked, *expected);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn backspace_ignores_selections_and_hostile_positions() {
|
||
let text = "()";
|
||
// A selection is deleted by `TextEdit`, not by the pair rule.
|
||
assert_eq!(pairs::on_backspace(text, 0..text.len()), None);
|
||
// Inverted out-of-bounds selection normalizes to a real selection.
|
||
let inverted = usize::from(9u8)..usize::from(0u8);
|
||
assert_eq!(pairs::on_backspace(text, inverted), None);
|
||
// Caret past the end clamps: no pair around it.
|
||
assert_eq!(pairs::on_backspace(text, 9..9), None);
|
||
// A caret splitting `é` floors to a boundary and finds no pair.
|
||
let hostile = "é()";
|
||
assert_eq!(pairs::on_backspace(hostile, 1..1), None);
|
||
assert_eq!(pairs::on_backspace(hostile, 2..2), None);
|
||
// One character before the pair: single delete, not a pair delete.
|
||
assert_eq!(pairs::on_backspace("a()", 1..1), None);
|
||
}
|
||
|
||
#[test]
|
||
fn multibyte_caret_lands_on_char_boundary() {
|
||
let text = "a😀b";
|
||
let at = text.find('b').unwrap();
|
||
let edit = keys::auto_close(text, at..at, '"').unwrap();
|
||
let new_text = applied(text, &edit);
|
||
assert_eq!(new_text, "a😀\"\"b");
|
||
let q = new_text.find('"').unwrap();
|
||
assert_eq!(edit.sel, q + 1..q + 1);
|
||
}
|
||
|
||
#[test]
|
||
fn closer_table() {
|
||
assert_eq!(pairs::closer('('), Some(')'));
|
||
assert_eq!(pairs::closer('['), Some(']'));
|
||
assert_eq!(pairs::closer('{'), Some('}'));
|
||
assert_eq!(pairs::closer('"'), Some('"'));
|
||
assert_eq!(pairs::closer('`'), Some('`'));
|
||
assert_eq!(pairs::closer('\''), None);
|
||
assert_eq!(pairs::closer('*'), None);
|
||
assert_eq!(pairs::closer('_'), None);
|
||
assert_eq!(pairs::closer('~'), None);
|
||
assert_eq!(pairs::closer('='), None);
|
||
assert_eq!(pairs::closer(')'), None);
|
||
}
|