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

130 lines
4.5 KiB
Rust

//! One violating and one clean note per rule.
use notescraft_lint::{Context, Issue, check, rules};
fn ids(text: &str) -> Vec<String> {
check(text, &Context::default()).into_iter().map(|i| i.rule).collect()
}
fn with_titles(text: &str) -> Vec<Issue> {
let ctx = Context {
titles: vec![("notes/Release notes.md".into(), "Release notes".into()), ("Home.md".into(), "Home".into())],
current_path: "notes/a.md".into(),
..Context::default()
};
check(text, &ctx)
}
#[test]
fn clean_note_reports_nothing() {
let note = "---\ntitle: A\ntags: [x]\n---\n\n# Title\n\nText with `code span` here.\n\n## Part\n\n- one\n- two\n\n```rust\nlet x = 1; \n### not a heading\n```\n\nEnd.\n";
assert_eq!(ids(note), Vec::<String>::new());
none(&with_titles("See [[Home]] and [[Release notes#Top|rel]] and `[[nope]]`.\n"));
}
#[test]
fn heading_increment() {
let issues = check("# A\n\n### B\n", &Context::default());
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].rule, "heading-increment");
assert_eq!(issues[0].line, 3);
let fix = issues[0].fix.as_ref().expect("fix");
assert_eq!(fix.label, "Make it ##");
assert!(fix.safe);
none(&ids("# A\n\n## B\n\n### C\n"));
}
#[test]
fn list_markers() {
assert_eq!(ids("- a\n* b\n+ c\n"), ["list-marker", "list-marker"]);
none(&ids("- a\n- b\n\n* * *\n"));
let mut ctx = Context::default();
ctx.style.list_marker = Some('*');
assert_eq!(check("- a\n- b\n", &ctx).len(), 2);
}
#[test]
fn spacing() {
assert_eq!(ids("a b\n"), ["double-space"]);
assert_eq!(ids("a \n"), ["trailing-space"]);
assert_eq!(ids("a\t\n"), ["trailing-space"]);
none(&ids("hard break \nnext\n"));
none(&ids(" indented\n| a | b |\n`a b`\n"));
}
#[test]
fn blank_lines() {
assert_eq!(ids("text\n# H\nmore\n"), ["blank-around-headings", "blank-around-headings"]);
assert_eq!(ids("text\n```\ncode\n```\nmore\n"), ["blank-around-fences", "blank-around-fences"]);
assert_eq!(ids("text\n- a\n- b\nafter\n"), ["blank-around-lists", "blank-around-lists"]);
none(&ids("text\n\n# H\n\n```\ncode\n```\n\n- a\n - b\n\nafter\n"));
none(&ids("---\na: 1\n---\n# H\n\ntext\n"));
}
#[test]
fn frontmatter() {
let issues = check("---\ntitle: A\ntags: x\n\n# Body\n", &Context::default());
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].rule, "frontmatter-unclosed");
assert!(issues[0].fix.as_ref().is_some_and(|f| f.safe));
assert_eq!(ids("---\ntitle: A\ntitle: B\n---\n"), ["frontmatter-duplicate-key"]);
none(&ids("---\n\ntext\n"));
}
#[test]
fn duplicate_headings() {
assert_eq!(ids("# A\n\n## Same\n\n## same\n"), ["duplicate-heading"]);
none(&ids("# A\n\n## One\n\n## Two\n"));
}
#[test]
fn unresolved_links() {
let issues = with_titles("See [[Releas notes]] and [[Home|h]] and [[Nope#x]] and ![[pic.png]].\n");
assert_eq!(issues.len(), 2);
let first = &issues[0];
assert_eq!(first.rule, "unresolved-link");
let fix = first.fix.as_ref().expect("replacement");
assert_eq!(fix.label, "Replace with Release notes");
assert!(!fix.safe);
assert_eq!(first.extra_fixes[0].label, "Create note");
assert!(issues[1].fix.is_none());
none(&with_titles("```\n[[nope]]\n```\n"));
}
#[test]
fn rule_switches_and_spelling() {
let mut ctx = Context::default();
ctx.rules.enabled.insert("double-space".into(), false);
none(&check("a b\n", &ctx));
ctx.rules.enabled.insert("trailing-space".into(), true);
ctx.rules.severity.insert("trailing-space".into(), notescraft_lint::Severity::Error);
assert_eq!(check("a \n", &ctx)[0].severity, notescraft_lint::Severity::Error);
ctx.rules.enabled.insert("spelling".into(), true);
none(&check("teh\n", &ctx));
assert!(rules().iter().any(|r| r.id == "spelling"));
}
#[test]
fn dismissed_issues_do_not_return() {
let mut ctx = Context::default();
let first = check("a b\nc d\n", &ctx);
assert_eq!(first.len(), 2);
ctx.dismissed.push(first[0].key());
let again = check("a b\nc d\n", &ctx);
assert_eq!(again.len(), 1);
assert_eq!(again[0].line, 2);
}
#[test]
fn sorted_and_serializable() {
let issues = check("text\n# H\n\n### X \n", &Context::default());
assert!(issues.windows(2).all(|w| w[0].range.start <= w[1].range.start));
let json = serde_json::to_string(&issues).expect("json");
let back: Vec<Issue> = serde_json::from_str(&json).expect("back");
assert_eq!(back, issues);
}
fn none<T: std::fmt::Debug>(v: &[T]) {
assert_eq!(format!("{v:?}"), "[]");
}