Files
Marcos Paulo 5e8020aa38
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: Obsidian basics, editor previews, graph rework, query blocks, stacked tabs, pop-out windows
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>
2026-10-19 11:21:19 -03:00

212 lines
7.6 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}