//! 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::::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"); } }