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

204 lines
11 KiB
Rust

//! The `lint.*` commands: sorted issues, one-edit fixes, stale refusal, dismissals that survive a reopen,
//! the learned style and hostile paths.
mod common;
use common::{on_disk, seed, temp_vault};
use notescraft_engine::{Registry, Session, adversarial_params, register_core};
use serde_json::{Value, json};
const MESSY: &str = "# A\n\n### B\n\ntext with gaps\n";
const CLEAN: &str = "# A\n\n## B\n\ntext with gaps\n";
fn setup(name: &str, files: &[(&str, &str)]) -> (Registry<Session>, Session, common::TempVault) {
let vault = temp_vault(name);
seed(vault.path(), files);
let mut registry = Registry::new();
register_core(&mut registry).expect("register");
let session = Session::open(vault.path()).expect("open");
(registry, session, vault)
}
fn run(registry: &Registry<Session>, session: &mut Session, id: &str, params: &Value) -> Value {
registry.run(id, session, params).unwrap_or_else(|error| panic!("{id}: {error:?}"))
}
fn code(registry: &Registry<Session>, session: &mut Session, id: &str, params: &Value) -> String {
match registry.run(id, session, params) {
Err(notescraft_engine::Error::Command { source, .. }) => source.code,
Err(other) => other.code().to_owned(),
Ok(value) => panic!("{id} succeeded: {value}"),
}
}
fn issues(registry: &Registry<Session>, session: &mut Session, path: &str) -> Vec<Value> {
run(registry, session, "lint.check", &json!({ "path": path }))["issues"].as_array().expect("issues").clone()
}
#[test]
fn check_returns_sorted_issues_for_a_messy_note_and_none_for_a_clean_one() {
let (registry, mut session, _vault) = setup("lint-check", &[("messy.md", MESSY), ("clean.md", CLEAN)]);
let result = run(&registry, &mut session, "lint.check", &json!({ "path": "messy.md" }));
let found = result["issues"].as_array().expect("issues");
assert!(found.len() >= 2);
let starts: Vec<u64> = found.iter().map(|i| i["range"]["start"].as_u64().expect("start")).collect();
assert!(starts.windows(2).all(|w| w[0] <= w[1]), "sorted by position");
assert!(found.iter().all(|i| i["key"].as_str().is_some_and(|k| !k.is_empty())));
assert_eq!(result["truncated"], false);
let counted: usize = ["error", "warning", "info"].iter().map(|k| usize::try_from(result["counts"][k].as_u64().expect("count")).expect("fits")).sum();
assert_eq!(counted, found.len());
assert_eq!(issues(&registry, &mut session, "clean.md"), Vec::<Value>::new());
}
#[test]
fn fix_applies_one_edit_and_a_second_try_is_stale() {
let (registry, mut session, vault) = setup("lint-fix", &[("messy.md", MESSY)]);
let all = issues(&registry, &mut session, "messy.md");
let heading = all.iter().find(|i| i["rule"] == "heading-increment").expect("heading issue");
let key = heading["key"].as_str().expect("key").to_owned();
let params = json!({ "path": "messy.md", "key": key });
assert_eq!(run(&registry, &mut session, "lint.fix", &params)["changed"], true);
let text = on_disk(vault.path(), "messy.md");
assert!(text.contains("## B") && text.contains("text with gaps"), "only that fix: {text}");
assert_eq!(code(&registry, &mut session, "lint.fix", &params), "stale");
}
#[test]
fn fix_in_an_open_buffer_is_one_undo_step() {
let (registry, mut session, vault) = setup("lint-undo", &[("messy.md", MESSY)]);
run(&registry, &mut session, "buffer.open", &json!({ "path": "messy.md" }));
let done = run(&registry, &mut session, "lint.fix_all_safe", &json!({ "path": "messy.md" }));
assert!(done["applied"].as_u64().expect("applied") >= 2);
assert!(session.buffer("messy.md").expect("buffer").is_dirty());
assert_eq!(on_disk(vault.path(), "messy.md"), MESSY, "not saved");
assert_eq!(run(&registry, &mut session, "buffer.undo", &json!({ "path": "messy.md" }))["undone"], true);
assert_eq!(session.buffer("messy.md").expect("buffer").text(), MESSY);
assert_eq!(run(&registry, &mut session, "buffer.undo", &json!({ "path": "messy.md" }))["undone"], false, "one step");
}
#[test]
fn fix_all_safe_fixes_the_note_and_leaves_it_clean() {
let (registry, mut session, vault) = setup("lint-all", &[("messy.md", MESSY)]);
let done = run(&registry, &mut session, "lint.fix_all_safe", &json!({ "path": "messy.md" }));
assert_eq!(done["changed"], true);
assert_eq!(done["skipped"], 0);
assert_eq!(on_disk(vault.path(), "messy.md"), CLEAN);
assert_eq!(issues(&registry, &mut session, "messy.md"), Vec::<Value>::new());
let again = run(&registry, &mut session, "lint.fix_all_safe", &json!({ "path": "messy.md" }));
assert_eq!((again["applied"].as_u64(), again["changed"].as_bool()), (Some(0), Some(false)));
}
#[test]
fn create_note_extra_fix_creates_the_target_without_editing_the_text() {
let (registry, mut session, vault) = setup("lint-create", &[("dir/a.md", "# A\n\nSee [[Brand New Thing]].\n")]);
let all = issues(&registry, &mut session, "dir/a.md");
let link = all.iter().find(|i| i["rule"] == "unresolved-link").expect("unresolved");
let key = link["key"].as_str().expect("key");
let done = run(&registry, &mut session, "lint.fix", &json!({ "path": "dir/a.md", "key": key, "fix": 0 }));
assert_eq!(done["created"], "dir/Brand New Thing.md");
assert!(vault.path().join("dir/Brand New Thing.md").exists());
assert_eq!(on_disk(vault.path(), "dir/a.md"), "# A\n\nSee [[Brand New Thing]].\n");
assert_eq!(issues(&registry, &mut session, "dir/a.md"), Vec::<Value>::new());
}
#[test]
fn dismiss_persists_across_reopen_and_stays_out_of_the_note_list() {
let (registry, mut session, vault) = setup("lint-dismiss", &[("messy.md", MESSY)]);
let before = issues(&registry, &mut session, "messy.md");
let key = before[0]["key"].as_str().expect("key").to_owned();
run(&registry, &mut session, "lint.dismiss", &json!({ "path": "messy.md", "key": key }));
assert_eq!(issues(&registry, &mut session, "messy.md").len(), before.len() - 1);
assert!(vault.path().join(".notescraft/lint-state.json").exists());
let listed = run(&registry, &mut session, "note.list", &Value::Null);
assert_eq!(listed["notes"], json!(["messy.md"]));
drop(session);
let mut reopened = Session::open(vault.path()).expect("reopen");
assert_eq!(issues(&registry, &mut reopened, "messy.md").len(), before.len() - 1);
assert_eq!(run(&registry, &mut reopened, "lint.state", &Value::Null)["count"], 1);
assert_eq!(code(&registry, &mut reopened, "lint.dismiss", &json!({ "path": "messy.md", "key": "nope:0000" })), "stale");
run(&registry, &mut reopened, "lint.state_clear", &json!({}));
assert_eq!(issues(&registry, &mut reopened, "messy.md").len(), before.len());
}
#[test]
fn style_is_learned_after_twenty_notes_with_a_dominant_marker() {
let files: Vec<(String, String)> = (0..24).map(|i| (format!("n{i}.md"), "# T\n\n* one\n* two\n".to_owned())).collect();
let refs: Vec<(&str, &str)> = files.iter().map(|(p, t)| (p.as_str(), t.as_str())).collect();
let (registry, mut session, _vault) = setup("lint-style", &refs);
let report = run(&registry, &mut session, "lint.style", &Value::Null);
assert_eq!(report["learned"]["list_marker"], "*");
let stat = &report["stats"][0];
assert_eq!((stat["value"].as_str(), stat["total"].as_u64()), (Some("*"), Some(24)));
session.write_note("dash.md", "# T\n\n- one\n").expect("write");
let after = run(&registry, &mut session, "lint.style", &Value::Null);
assert_eq!(after["stats"][0]["total"], 25, "incremental");
let marker_issue = issues(&registry, &mut session, "dash.md");
assert!(marker_issue.iter().any(|i| i["rule"] == "list-marker"));
}
#[test]
fn rules_lists_every_rule_with_effective_severity() {
let (registry, mut session, _vault) = setup("lint-rules", &[("a.md", "# A\n")]);
let result = run(&registry, &mut session, "lint.rules", &Value::Null);
let rules = result["rules"].as_array().expect("rules");
assert!(rules.iter().any(|r| r["id"] == "heading-increment" && r["enabled"] == true));
assert!(rules.iter().any(|r| r["id"] == "spelling" && r["enabled"] == false));
}
#[test]
fn hostile_paths_and_params_are_refused() {
let (registry, mut session, _vault) = setup("lint-hostile", &[("a.md", "# A\n")]);
for path in ["../a.md", "/etc/passwd.md", "a.txt", "", "missing.md"] {
for id in ["lint.check", "lint.fix_all_safe"] {
let outcome = registry.run(id, &mut session, &json!({ "path": path }));
assert!(outcome.is_err(), "{id} {path:?}");
}
}
assert!(registry.run("lint.fix", &mut session, &json!({ "path": "a.md", "key": "" })).is_err());
for id in ["lint.check", "lint.fix", "lint.fix_all_safe", "lint.dismiss", "lint.state_clear", "lint.rules", "lint.state", "lint.style"] {
for params in adversarial_params() {
let _ = registry.run(id, &mut session, &params);
}
}
}
fn set(registry: &Registry<Session>, session: &mut Session, key: &str, value: bool) {
run(registry, session, "settings.set", &json!({ "key": key, "value": value }));
}
#[test]
fn per_aspect_switches_clear_the_learned_list_marker() {
let files: Vec<(String, String)> = (0..24).map(|i| (format!("n{i}.md"), "# T\n\n* one\n* two\n".to_owned())).collect();
let refs: Vec<(&str, &str)> = files.iter().map(|(p, t)| (p.as_str(), t.as_str())).collect();
let (registry, mut session, _vault) = setup("lint-aspect", &refs);
let config = temp_vault("lint-aspect-cfg");
session.use_settings_dir(config.path());
session.write_note("dash.md", "# T\n\n- one\n").expect("write");
assert!(issues(&registry, &mut session, "dash.md").iter().any(|i| i["rule"] == "list-marker"));
set(&registry, &mut session, "session.lint.learn_list_marker", false);
assert!(!session.config().lint.learn_list_marker && session.config().lint.learn_date_format && session.config().lint.learn_style);
assert!(!issues(&registry, &mut session, "dash.md").iter().any(|i| i["rule"] == "list-marker"), "marker no longer learned");
set(&registry, &mut session, "session.lint.learn_list_marker", true);
set(&registry, &mut session, "session.lint.learn_date_format", false);
assert!(issues(&registry, &mut session, "dash.md").iter().any(|i| i["rule"] == "list-marker"), "date switch leaves markers alone");
}
#[test]
fn fix_on_save_fixes_once_in_one_undo_step_and_is_off_by_default() {
let (registry, mut session, vault) = setup("lint-save", &[("messy.md", MESSY)]);
let config = temp_vault("lint-save-cfg");
session.use_settings_dir(config.path());
run(&registry, &mut session, "buffer.open", &json!({ "path": "messy.md" }));
run(&registry, &mut session, "buffer.edit", &json!({ "path": "messy.md", "start": 0, "end": 0, "text": "x" }));
run(&registry, &mut session, "buffer.save", &json!({ "path": "messy.md" }));
assert!(on_disk(vault.path(), "messy.md").contains("text with gaps"), "off by default");
set(&registry, &mut session, "session.lint.fix_on_save", true);
run(&registry, &mut session, "buffer.edit", &json!({ "path": "messy.md", "start": 0, "end": 0, "text": "y" }));
run(&registry, &mut session, "buffer.save", &json!({ "path": "messy.md" }));
assert!(on_disk(vault.path(), "messy.md").contains("text with gaps"));
let saved = session.buffer("messy.md").expect("buffer");
assert!(!saved.is_dirty() && saved.text() == on_disk(vault.path(), "messy.md"));
run(&registry, &mut session, "buffer.save", &json!({ "path": "messy.md" }));
assert_eq!(run(&registry, &mut session, "buffer.undo", &json!({ "path": "messy.md" }))["undone"], true, "the fix is undoable");
}