15d7f5e873
- 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>
204 lines
11 KiB
Rust
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(®istry, &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(®istry, &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(®istry, &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(®istry, &mut session, "lint.fix", ¶ms)["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(®istry, &mut session, "lint.fix", ¶ms), "stale");
|
|
}
|
|
|
|
#[test]
|
|
fn fix_in_an_open_buffer_is_one_undo_step() {
|
|
let (registry, mut session, vault) = setup("lint-undo", &[("messy.md", MESSY)]);
|
|
run(®istry, &mut session, "buffer.open", &json!({ "path": "messy.md" }));
|
|
let done = run(®istry, &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(®istry, &mut session, "buffer.undo", &json!({ "path": "messy.md" }))["undone"], true);
|
|
assert_eq!(session.buffer("messy.md").expect("buffer").text(), MESSY);
|
|
assert_eq!(run(®istry, &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(®istry, &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(®istry, &mut session, "messy.md"), Vec::<Value>::new());
|
|
let again = run(®istry, &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(®istry, &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(®istry, &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(®istry, &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(®istry, &mut session, "messy.md");
|
|
let key = before[0]["key"].as_str().expect("key").to_owned();
|
|
run(®istry, &mut session, "lint.dismiss", &json!({ "path": "messy.md", "key": key }));
|
|
assert_eq!(issues(®istry, &mut session, "messy.md").len(), before.len() - 1);
|
|
assert!(vault.path().join(".notescraft/lint-state.json").exists());
|
|
let listed = run(®istry, &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(®istry, &mut reopened, "messy.md").len(), before.len() - 1);
|
|
assert_eq!(run(®istry, &mut reopened, "lint.state", &Value::Null)["count"], 1);
|
|
assert_eq!(code(®istry, &mut reopened, "lint.dismiss", &json!({ "path": "messy.md", "key": "nope:0000" })), "stale");
|
|
run(®istry, &mut reopened, "lint.state_clear", &json!({}));
|
|
assert_eq!(issues(®istry, &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(®istry, &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(®istry, &mut session, "lint.style", &Value::Null);
|
|
assert_eq!(after["stats"][0]["total"], 25, "incremental");
|
|
let marker_issue = issues(®istry, &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(®istry, &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, ¶ms);
|
|
}
|
|
}
|
|
}
|
|
|
|
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(®istry, &mut session, "dash.md").iter().any(|i| i["rule"] == "list-marker"));
|
|
set(®istry, &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(®istry, &mut session, "dash.md").iter().any(|i| i["rule"] == "list-marker"), "marker no longer learned");
|
|
set(®istry, &mut session, "session.lint.learn_list_marker", true);
|
|
set(®istry, &mut session, "session.lint.learn_date_format", false);
|
|
assert!(issues(®istry, &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(®istry, &mut session, "buffer.open", &json!({ "path": "messy.md" }));
|
|
run(®istry, &mut session, "buffer.edit", &json!({ "path": "messy.md", "start": 0, "end": 0, "text": "x" }));
|
|
run(®istry, &mut session, "buffer.save", &json!({ "path": "messy.md" }));
|
|
assert!(on_disk(vault.path(), "messy.md").contains("text with gaps"), "off by default");
|
|
set(®istry, &mut session, "session.lint.fix_on_save", true);
|
|
run(®istry, &mut session, "buffer.edit", &json!({ "path": "messy.md", "start": 0, "end": 0, "text": "y" }));
|
|
run(®istry, &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(®istry, &mut session, "buffer.save", &json!({ "path": "messy.md" }));
|
|
assert_eq!(run(®istry, &mut session, "buffer.undo", &json!({ "path": "messy.md" }))["undone"], true, "the fix is undoable");
|
|
}
|