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

155 lines
8.9 KiB
Rust

//! The search operators end to end over the public API: `section:`,
//! `block:`, `task:`, `task-todo:`, `task-done:` and the case switch, alone
//! and composed with `path:`, `file:`, `tag:`, `line:`, `-term` and `OR`.
//!
//! Every row is `(query, case_sensitive, notes it must hit)` over one small
//! vault, so a regression names the row that broke.
use notescraft_index::search::{MAX_CANDIDATES, SearchIndex, SearchOptions, SearchQuery};
/// One note per shape: sections, blocks, tasks, fences, CRLF and unicode.
fn vault() -> SearchIndex {
let mut notes = SearchIndex::new();
notes.update("n/sections.md", "# Alpha\n\nalpha text here\n\n## Beta\n\nbeta text with gamma\n\n# Delta\n\ndelta text\n").unwrap();
notes.update("n/blocks.md", "alpha in one block\n\nbeta in another\n\nalpha beta together in a block\n").unwrap();
notes.update("n/tasks.md", "- [ ] alpha todo #work\n\n- [x] beta done\n> - [ ] gamma quoted\n - [ ] nested alpha\n\n1. [x] ordered delta\n").unwrap();
notes.update("n/fenced.md", "```md\n# Fake heading\n\n- [ ] fake task\n```\n\n# Real heading\n\n- [x] real task\n").unwrap();
notes.update("play/n/other.md", "alpha beta\n").unwrap();
notes.update("u/unicode.md", "# 見出し\n\n漢字 ひらがな #日本語\n\n- [ ] 漢字 task\n").unwrap();
// A CRLF note: the `\r` is no marker for headings, blocks or tasks.
notes.update("work/n/crlf.md", "# Alpha\r\nalpha one\r\n\r\nbeta one\r\n\r\n- [ ] alpha task\r\n").unwrap();
notes
}
fn search(query: &str, sensitive: bool) -> Vec<String> {
let options = if sensitive { SearchOptions::matching_case() } else { SearchOptions::new() };
let parsed = SearchQuery::parse_with(query, &options).unwrap_or_else(|error| panic!("{query:?} must parse: {error}"));
let mut paths: Vec<String> = vault().search(&parsed, MAX_CANDIDATES).into_iter().map(|hit| hit.path).collect();
paths.sort();
paths
}
/// `(query, case_sensitive, notes it must hit)`.
const ROWS: &[(&str, bool, &[&str])] = &[
// section:
("section:(alpha text)", false, &["n/sections.md"]),
("section:(alpha gamma)", false, &["n/sections.md", "n/tasks.md"]),
("section:(alpha delta)", false, &["n/tasks.md"]),
("section:(gamma delta)", false, &["n/tasks.md"]),
("section:(delta text)", false, &["n/sections.md"]),
("section:(alpha one)", false, &["n/blocks.md", "work/n/crlf.md"]),
("section:(alpha beta)", false, &["n/blocks.md", "n/sections.md", "n/tasks.md", "play/n/other.md", "work/n/crlf.md"]),
("section:alpha", false, &["n/blocks.md", "n/sections.md", "n/tasks.md", "play/n/other.md", "work/n/crlf.md"]),
("section:nosuch", false, &[]),
("section:(見出し 漢字)", false, &["u/unicode.md"]),
("section:(漢字 ひらがな)", false, &["u/unicode.md"]),
("section:(fake heading)", false, &["n/fenced.md"]),
("section:(fake real)", false, &[]),
("section:(real task)", false, &["n/fenced.md"]),
("section:(todo nested)", false, &["n/tasks.md"]),
("section:alpha path:n/sections.md", false, &["n/sections.md"]),
("section:alpha -path:n/", false, &["play/n/other.md", "work/n/crlf.md"]),
("section:alpha tag:#work", false, &["n/tasks.md"]),
("section:alpha -alpha", false, &[]),
("section:alpha file:Other", false, &["play/n/other.md"]),
("section:(alpha gamma) OR section:(real task)", false, &["n/fenced.md", "n/sections.md", "n/tasks.md"]),
("(section:alpha OR section:fake)", false, &["n/blocks.md", "n/fenced.md", "n/sections.md", "n/tasks.md", "play/n/other.md", "work/n/crlf.md"]),
("section:alpha line:(nested alpha)", false, &["n/tasks.md"]),
// block:
("block:(alpha beta)", false, &["n/blocks.md", "n/tasks.md", "play/n/other.md"]),
("block:(alpha in one block)", false, &["n/blocks.md"]),
("block:together", false, &["n/blocks.md"]),
("block:(together in)", false, &["n/blocks.md"]),
("block:(alpha one)", false, &["n/blocks.md", "work/n/crlf.md"]),
("block:(alpha one beta)", false, &[]),
("block:fake", false, &["n/fenced.md"]),
("block:real", false, &["n/fenced.md"]),
("block:(fake real)", false, &[]),
("block:fake real", false, &["n/fenced.md"]),
("block:(fake task)", false, &["n/fenced.md"]),
("block:漢字", false, &["u/unicode.md"]),
("block:alpha path:work/", false, &["work/n/crlf.md"]),
("block:alpha -block", false, &["n/sections.md", "n/tasks.md", "play/n/other.md", "work/n/crlf.md"]),
("block:alpha -one", false, &["n/sections.md", "n/tasks.md", "play/n/other.md"]),
("block:(alpha beta) tag:#work", false, &["n/tasks.md"]),
("block:(delta text)", false, &["n/sections.md"]),
("block:gamma", false, &["n/sections.md", "n/tasks.md"]),
("block:(quoted nested)", false, &["n/tasks.md"]),
("block:(beta done quoted)", false, &["n/tasks.md"]),
// task:
("task:alpha", false, &["n/tasks.md", "work/n/crlf.md"]),
("task:(alpha nested)", false, &["n/tasks.md"]),
("task:(alpha beta)", false, &[]),
("task:gamma", false, &["n/tasks.md"]),
("task:quoted", false, &["n/tasks.md"]),
("task:ordered", false, &["n/tasks.md"]),
("task:fake", false, &[]),
("task:real", false, &["n/fenced.md"]),
("task:漢字", false, &["u/unicode.md"]),
("task:todo", false, &["n/tasks.md"]),
("task:alpha tag:#work", false, &["n/tasks.md"]),
("task:alpha -path:play/", false, &["n/tasks.md", "work/n/crlf.md"]),
("task:alpha path:work/", false, &["work/n/crlf.md"]),
("task:alpha file:Tasks", false, &["n/tasks.md"]),
("task:alpha OR task:gamma", false, &["n/tasks.md", "work/n/crlf.md"]),
("task:alpha -task-done:", false, &["work/n/crlf.md"]),
("task-todo:", false, &["n/tasks.md", "u/unicode.md", "work/n/crlf.md"]),
("task-done:", false, &["n/fenced.md", "n/tasks.md"]),
("task-todo: -path:work/", false, &["n/tasks.md", "u/unicode.md"]),
("task-done:delta", false, &["n/tasks.md"]),
("task-todo:delta", false, &[]),
("task-done:(ordered delta)", false, &["n/tasks.md"]),
// The structural operators stay case-insensitive, the terms do not.
("match-case: task-done: real", false, &["n/fenced.md"]),
("match-case: task-done: REAL", false, &[]),
// match-case: and ignore-case:
("match-case: alpha", false, &["n/blocks.md", "n/sections.md", "n/tasks.md", "play/n/other.md", "work/n/crlf.md"]),
("match-case: Alpha", false, &["n/sections.md", "work/n/crlf.md"]),
("match-case: ALPHA", false, &[]),
("ignore-case: ALPHA", false, &["n/blocks.md", "n/sections.md", "n/tasks.md", "play/n/other.md", "work/n/crlf.md"]),
("match-case: ALPHA ignore-case:", false, &["n/blocks.md", "n/sections.md", "n/tasks.md", "play/n/other.md", "work/n/crlf.md"]),
("ignore-case: ALPHA match-case:", false, &[]),
("match-case: \"alpha text\"", false, &["n/sections.md"]),
("match-case: \"Alpha text\"", false, &[]),
("match-case: \"Alpha text\" OR ignore-case: \"alpha text\"", false, &["n/sections.md"]),
("match-case: alpha -path:n/", false, &["play/n/other.md", "work/n/crlf.md"]),
("match-case: alpha path:n/sections.md", false, &["n/sections.md"]),
("match-case: alpha tag:#work", false, &["n/tasks.md"]),
("match-case: alpha -ALPHA", false, &["n/blocks.md", "n/sections.md", "n/tasks.md", "play/n/other.md", "work/n/crlf.md"]), // the negation is cased too: `-ALPHA` subtracts nothing
("match-case: crlf", false, &["work/n/crlf.md"]),
("match-case: Tasks", false, &[]),
("match-case: 漢字", false, &["u/unicode.md"]),
("match-case: 漢字の", false, &[]),
("match-case: section:(ALPHA)", false, &["n/blocks.md", "n/sections.md", "n/tasks.md", "play/n/other.md", "work/n/crlf.md"]),
// The options parameter says the same as the in-query flag.
("alpha", true, &["n/blocks.md", "n/sections.md", "n/tasks.md", "play/n/other.md", "work/n/crlf.md"]),
("Alpha", true, &["n/sections.md", "work/n/crlf.md"]),
("ALPHA", true, &[]),
("alpha", false, &["n/blocks.md", "n/sections.md", "n/tasks.md", "play/n/other.md", "work/n/crlf.md"]),
("\"alpha text\"", true, &["n/sections.md"]),
("section:(ALPHA text)", true, &["n/sections.md"]),
];
#[test]
fn every_operator_row_behaves() {
assert!(ROWS.len() >= 50, "the table keeps {} rows", ROWS.len());
let mut broken: Vec<String> = Vec::new();
for (query, sensitive, want) in ROWS {
let mut expected: Vec<String> = want.iter().map(|path| (*path).to_string()).collect();
expected.sort();
let got = search(query, *sensitive);
if got != expected {
broken.push(format!("{query:?} (case_sensitive={sensitive}) got {got:?}, want {expected:?}"));
}
}
assert_eq!(broken, Vec::<String>::new());
}
#[test]
fn the_case_switch_needs_a_term_to_search_for() {
for query in ["match-case:", "ignore-case:", "match-case: ignore-case:", "-match-case:"] {
assert!(SearchQuery::parse(query).is_err(), "{query:?} searches for nothing");
}
assert!(SearchQuery::parse_with("match-case:", &SearchOptions::matching_case()).is_err());
}