Files
Marcos Paulo 5e8020aa38
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: Obsidian basics, editor previews, graph rework, query blocks, stacked tabs, pop-out windows
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>
2026-10-19 11:21:19 -03:00

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