5e8020aa38
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>
119 lines
5.4 KiB
Rust
119 lines
5.4 KiB
Rust
//! The `labels` batch check: proposing and applying language labels for unlabeled code blocks.
|
|
|
|
use notescraft_engine::{BatchDecision, Change, ChangeKind, Checks, Status, apply_changes, propose_note};
|
|
use notescraft_lint::{Edit, Fix, Issue, Range as LintRange, Severity};
|
|
|
|
/// The `Checks` of a labels-only batch: no JSON formatting, no lint fixes.
|
|
const LABELS: Checks = Checks { json: false, lint: false, labels: true, replace: None };
|
|
|
|
/// An issue whose fix is one edit replacing `start..end` with `replacement`.
|
|
fn lint_issue(rule: &str, start: usize, end: usize, replacement: &str, safe: bool) -> Issue {
|
|
Issue {
|
|
rule: rule.to_owned(),
|
|
severity: Severity::Info,
|
|
range: LintRange { start, end },
|
|
line: 1,
|
|
message: String::new(),
|
|
fix: Some(Fix { label: String::new(), edits: vec![Edit { range: LintRange { start, end }, text: replacement.to_owned() }], safe }),
|
|
extra_fixes: Vec::new(),
|
|
hash: 0,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn unlabeled_fences_get_label_changes_that_need_review() {
|
|
let note = "# T\n\n```\nfn main() {\n let x = 1;\n}\n```\n";
|
|
let proposal = propose_note("n.md", note, &[], LABELS);
|
|
assert_eq!(proposal.status, Status::NeedsReview);
|
|
let c = proposal.changes.first().expect("one change");
|
|
assert_eq!(c.kind, ChangeKind::Label);
|
|
let fence = note.find("```\n").expect("the opening fence") + "```".len();
|
|
assert_eq!(c.range, fence..fence, "zero-length range right after the fence run");
|
|
assert_eq!(c.before, "");
|
|
assert_eq!(c.after, "rust");
|
|
assert!(!c.safe, "a guess always needs review");
|
|
assert_eq!(c.rule, None);
|
|
assert_eq!(c.decision, BatchDecision::Pending);
|
|
}
|
|
|
|
#[test]
|
|
fn labeled_fences_and_labels_off_get_no_label_changes() {
|
|
for note in ["```rust\nfn main() {}\n```\n", "~~~python\nprint(1)\n~~~\n"] {
|
|
let proposal = propose_note("n.md", note, &[], LABELS);
|
|
assert_eq!(proposal.changes, Vec::new(), "{note:?}");
|
|
assert_eq!(proposal.status, Status::Clean, "{note:?}");
|
|
}
|
|
let unlabeled = "```\nfn main() {\n let x = 1;\n}\n```\n";
|
|
let off = Checks { labels: false, ..LABELS };
|
|
let proposal = propose_note("n.md", unlabeled, &[], off);
|
|
assert_eq!(proposal.changes, Vec::new());
|
|
assert_eq!(proposal.status, Status::Clean);
|
|
}
|
|
|
|
#[test]
|
|
fn an_unlabeled_json_block_keeps_its_json_change_only() {
|
|
let note = "```\n{\"a\":1,\"b\":2}\n```\n";
|
|
let both = || Checks { json: true, ..LABELS };
|
|
let proposal = propose_note("n.md", note, &[], both());
|
|
assert_eq!(proposal.changes.len(), 1, "no label change on top of the JSON change");
|
|
assert_eq!(proposal.changes.first().map(|c| c.kind), Some(ChangeKind::Json));
|
|
// already-formatted unlabeled JSON still gets its label: there is no JSON change to keep
|
|
let formatted = "```\n{\n \"a\": 1\n}\n```\n";
|
|
let proposal = propose_note("n.md", formatted, &[], both());
|
|
let c = proposal.changes.first().expect("one change");
|
|
assert_eq!(c.kind, ChangeKind::Label);
|
|
assert_eq!(c.after, "json");
|
|
assert!(!c.safe);
|
|
}
|
|
|
|
#[test]
|
|
fn applying_a_label_change_inserts_right_after_the_fence_run() {
|
|
let note = "# T\n\n```\ndef add(a, b):\n return a + b\n```\n";
|
|
let mut proposal = propose_note("n.md", note, &[], LABELS);
|
|
for c in &mut proposal.changes {
|
|
c.decision = BatchDecision::Accept;
|
|
}
|
|
let (new, applied, skipped) = apply_changes(note, &proposal.changes);
|
|
assert_eq!(applied, vec![0]);
|
|
assert_eq!(skipped, Vec::<usize>::new());
|
|
assert_eq!(new, "# T\n\n```python\ndef add(a, b):\n return a + b\n```\n");
|
|
}
|
|
|
|
#[test]
|
|
fn a_label_point_inside_another_change_range_is_dropped() {
|
|
let note = "```\nfn main() {\n let x = 1;\n}\n```\n";
|
|
let fence_line = note.find("```\n").expect("the opening fence line") + "```\n".len();
|
|
let issue = lint_issue("whole-line", 0, fence_line, "```rust\n", true);
|
|
let with_lint = Checks { lint: true, ..LABELS };
|
|
let proposal = propose_note("n.md", note, std::slice::from_ref(&issue), with_lint);
|
|
assert_eq!(proposal.changes.len(), 1, "the label inside the lint change is dropped");
|
|
assert_eq!(proposal.changes.first().map(|c| c.kind), Some(ChangeKind::Lint));
|
|
}
|
|
|
|
#[test]
|
|
fn prose_and_ambiguous_blocks_get_no_label_change() {
|
|
for note in ["```\njust a sentence\n```\n", "```\na: b\n```\n", "```\nx = 1\n```\n"] {
|
|
let proposal = propose_note("n.md", note, &[], LABELS);
|
|
assert_eq!(proposal.changes, Vec::new(), "{note:?}");
|
|
assert_eq!(proposal.status, Status::Clean, "{note:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn many_blocks_in_one_note_each_get_their_own_label_change() {
|
|
let note = "```\nfn main() {\n let x = 1;\n}\n```\n\ntext\n\n```\n$ cargo build\n$ cargo test\n```\n\n```\ndef add(a, b):\n return a + b\n```\n";
|
|
let proposal = propose_note("n.md", note, &[], LABELS);
|
|
let after: Vec<&str> = proposal.changes.iter().map(|c: &Change| c.after.as_str()).collect();
|
|
assert_eq!(after, vec!["rust", "bash", "python"]);
|
|
assert_eq!(proposal.status, Status::NeedsReview);
|
|
for (change, language) in proposal.changes.iter().zip(["rust", "bash", "python"]) {
|
|
let fence = match language {
|
|
"rust" => note.find("```\nfn main"),
|
|
"bash" => note.find("```\n$ cargo build"),
|
|
_ => note.find("```\ndef add"),
|
|
}
|
|
.expect("the opening fence");
|
|
assert_eq!(change.range, fence + "```".len()..fence + "```".len(), "{language} inserts after its own fence run");
|
|
}
|
|
}
|