Files
Marcos Paulo 15d7f5e873 feat: editor blocks and lint, vault knowledge, calendar plugin, macOS menu
- notescraft-lint (rules, safe fixes, learned style) and notescraft-blocks (block model and edits); engine lint.* and block.* commands with CLI and MCP parity
- Lint tab, underlines, slash menu with preview in Source and Visual, block grip and menu, Settings > Editor and lint, Vim block keys
- Visual view: tables, note embeds, %% comments, wrapping property chips
- Vault knowledge (Related tab, assistant sources, Settings) and calendar plugin
- Sidebar top padding fixed, dot grid follows PaperCraft spacing, right-card tabs no longer cover the collapse button

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 12:36:00 -03:00

206 lines
7.4 KiB
Rust

//! Block parsing, block operations and component inserts.
use notescraft_blocks::{
BlockKind as K, Component, Direction, Target, block_at, delete_block, duplicate_block, insert_component, move_block, parse_blocks, turn_into,
};
fn kinds(text: &str) -> Vec<K> {
parse_blocks(text).into_iter().map(|b| b.kind).collect()
}
fn slice(text: &str, i: usize) -> &str {
&text[parse_blocks(text)[i].range.clone()]
}
#[test]
fn every_kind_is_parsed() {
let text = "---\ntitle: x\n---\n\n# Head\n\npara\nline two\n\n> quote\n\n> [!note] T\n> body\n\n- [ ] todo\n- [x] done\n- item\n1. one\n\n| a | b |\n| - | - |\n\n```rust\nfn x() {}\n```\n\n---\n\n![[note]]\n";
assert_eq!(
kinds(text),
vec![
K::Frontmatter,
K::Heading(1),
K::Paragraph,
K::Quote,
K::Callout,
K::TaskItem { checked: false },
K::TaskItem { checked: true },
K::ListItem { ordered: false },
K::ListItem { ordered: true },
K::Table,
K::CodeFence,
K::Divider,
K::Embed
]
);
assert_eq!(slice(text, 2), "para\nline two");
}
#[test]
fn fence_with_slash_hash_and_blank_lines_is_one_block() {
let text = "intro\n\n```\n# not heading\n/ not slash\n\n- not list\n```\n\nafter";
assert_eq!(kinds(text), vec![K::Paragraph, K::CodeFence, K::Paragraph]);
let b = &parse_blocks(text)[1];
assert_eq!(&text[b.text_range.clone()], "# not heading\n/ not slash\n\n- not list\n");
}
#[test]
fn unclosed_fence_runs_to_end() {
let text = "```\nabc\n\ndef";
assert_eq!(kinds(text), vec![K::CodeFence]);
assert_eq!(parse_blocks(text)[0].range, 0..text.len());
}
#[test]
fn list_item_keeps_children() {
let text = "- a\n - b\n\n more\n- c";
assert_eq!(parse_blocks(text).len(), 2);
assert_eq!(slice(text, 0), "- a\n - b\n\n more");
assert_eq!(slice(text, 1), "- c");
}
#[test]
fn text_ranges_skip_markers() {
let text = "## Hi\n\n- [x] done\n\n> [!tip] t";
let bs = parse_blocks(text);
assert_eq!(&text[bs[0].text_range.clone()], "Hi");
assert_eq!(&text[bs[1].text_range.clone()], "done");
assert_eq!(&text[bs[2].text_range.clone()], "[!tip] t");
}
#[test]
fn block_at_finds_block() {
let text = "one\n\ntwo";
assert_eq!(block_at(text, 0), Some(0));
assert_eq!(block_at(text, 3), Some(0));
assert_eq!(block_at(text, 4), None);
assert_eq!(block_at(text, 5), Some(1));
}
#[test]
fn move_round_trip_is_identity_and_single_replace() {
let text = "# T\n\npara\n\n- a\n- b\n child\n\n```\ncode\n```\n\n> q";
let n = parse_blocks(text).len();
for i in 0..n - 1 {
let down = move_block(text, i, Direction::Down).unwrap();
let (range, new) = &down.replace;
let mut applied = text.to_owned();
applied.replace_range(range.clone(), new);
assert_eq!(applied, down.text, "replace reproduces text");
assert_ne!(down.text, text);
let back = move_block(&down.text, i + 1, Direction::Up).unwrap();
assert_eq!(back.text, text, "round trip at {i}");
}
}
#[test]
fn move_keeps_tight_list_and_blank_separator() {
let text = "a\n\nb\nc\n\nd";
let up = move_block(text, 1, Direction::Up).unwrap();
assert_eq!(up.text, "b\nc\n\na\n\nd");
assert_eq!(up.caret, 0);
let down = move_block(text, 0, Direction::Down).unwrap();
assert_eq!(&down.text[down.caret..=down.caret], "a");
}
#[test]
fn move_edges_and_frontmatter_error() {
assert!(move_block("a\n\nb", 0, Direction::Up).is_err());
assert!(move_block("a\n\nb", 1, Direction::Down).is_err());
assert!(move_block("a", 5, Direction::Down).is_err());
assert!(move_block("---\na: 1\n---\n\nbody", 1, Direction::Up).is_err());
}
#[test]
fn duplicate_and_delete() {
let d = duplicate_block("a\n\nb", 0).unwrap();
assert_eq!(d.text, "a\n\na\n\nb");
assert_eq!(d.caret, 3);
let d = duplicate_block("- a\n- b", 0).unwrap();
assert_eq!(d.text, "- a\n- a\n- b");
assert_eq!(delete_block("a\n\nb\n\nc", 1).unwrap().text, "a\n\nc");
assert_eq!(delete_block("a\n\nb", 1).unwrap().text, "a");
assert_eq!(delete_block("a", 0).unwrap().text, "");
assert!(delete_block("a", 1).is_err());
}
#[test]
fn turn_into_keeps_text() {
let p = turn_into("hello world", 0, &Target::Heading(2)).unwrap();
assert_eq!(p.text, "## hello world");
let q = turn_into(&p.text, 0, &Target::Quote).unwrap();
assert_eq!(q.text, "> hello world");
let t = turn_into(&q.text, 0, &Target::Task).unwrap();
assert_eq!(t.text, "- [ ] hello world");
let c = turn_into(&t.text, 0, &Target::Callout("warning".into())).unwrap();
assert_eq!(c.text, "> [!warning]\n> hello world");
let back = turn_into(&c.text, 0, &Target::Paragraph).unwrap();
assert_eq!(back.text, "hello world");
assert_eq!(back.caret, back.text.len());
}
#[test]
fn turn_into_list_keeps_indent_and_children() {
let t = turn_into(" - item\n child", 0, &Target::Task).unwrap();
assert_eq!(t.text, " - [ ] item\n child");
let p = turn_into("- [x] item\n child", 0, &Target::Paragraph).unwrap();
assert_eq!(p.text, "item\n child");
}
#[test]
fn turn_into_refuses_unsupported() {
assert!(turn_into("```\nx\n```", 0, &Target::Paragraph).is_err());
assert!(turn_into("a", 0, &Target::Heading(7)).is_err());
assert!(turn_into("a", 3, &Target::Quote).is_err());
}
#[test]
fn components() {
let t = insert_component("ab", 2, &Component::Table).unwrap();
assert!(t.text.starts_with("ab\n| | |\n| --- | --- |"));
assert_eq!(&t.text[..t.caret], "ab\n| ");
assert_eq!(kinds(&t.text), vec![K::Paragraph, K::Table]);
let c = insert_component("", 0, &Component::Callout).unwrap();
assert_eq!(c.text, "> [!note]\n> ");
assert_eq!(c.caret, c.text.len());
for comp in [Component::Calendar, Component::Diagram, Component::Whiteboard, Component::Template("Daily".into())] {
let e = insert_component("x\n\ny", 3, &comp).unwrap();
assert!(e.text.is_char_boundary(e.caret));
assert!(e.text.ends_with("\ny"));
}
let d = insert_component("", 0, &Component::Diagram).unwrap();
assert_eq!(kinds(&d.text), vec![K::CodeFence]);
assert!(d.text[..d.caret].ends_with("A --> B"));
}
#[test]
fn hostile_inputs_never_panic() {
assert!(insert_component("é", 1, &Component::Table).is_err());
assert!(insert_component("a", 9, &Component::Table).is_err());
for text in ["", "\n", "é\n\n日本語\n- é\n 日", "```", "~~~\n", "---", "---\n", "> ", "|", "- [", "- [x", "![[", "![[]]", "#", "\r\n\r\n- a\r\n b\r\n"]
{
let n = parse_blocks(text).len();
for i in 0..=n {
let _ = move_block(text, i, Direction::Up);
let _ = move_block(text, i, Direction::Down);
let _ = duplicate_block(text, i);
let _ = delete_block(text, i);
let _ = turn_into(text, i, &Target::Heading(3));
}
for o in 0..=text.len() + 1 {
let _ = block_at(text, o);
let _ = insert_component(text, o, &Component::Callout);
}
}
}
#[test]
fn hundred_thousand_lines() {
let text: String = (0..100_000).map(|i| if i % 3 == 0 { format!("- item {i}\n child\n") } else { format!("line {i}\n\n") }).collect();
let bs = parse_blocks(&text);
assert!(bs.len() > 50_000);
let blanks = "\n".repeat(100_000);
let t = format!("- a\n{blanks}x");
assert_eq!(parse_blocks(&t).len(), 2);
}