///|
// fts5_sanitize.mbt — FTS5 query sanitization for safe use in SQLite MATCH.
//
// Strategy (mirrors hermes _sanitize_fts5_query, simplified):
// - Tokenize on whitespace
// - Wrap each token in double quotes
// - Escape inner double quotes by doubling: `"` → `""`
// - Drop tokens that are empty after escaping
// - If no tokens remain, return "" (caller should skip the query)
//
// This approach treats every token as a literal phrase, preventing FTS5
// operator injection (AND/OR/NOT/NEAR, unbalanced parens, stray *, etc.).
// It is intentionally simpler than the hermes version (which preserves
// balanced quoted phrases); for mnemo all tokens are quoted literals.
///| Sanitize a user-supplied string for safe use in FTS5 MATCH expressions.
/// Returns an empty string when input is blank — caller must treat that as
/// "no search" and return an empty result set.
///
/// Pure MoonBit implementation — cross-target. Tokenize on any ASCII
/// whitespace (space, tab, newline, carriage return, form feed, vertical
/// tab), wrap each non-empty token in double quotes, and double any
/// inner `"` to escape it.
pub fn sanitize_fts5_query(q : String) -> String {
let view = q.view()
let tokens : Array[String] = []
let buf = StringBuilder::new()
let flush = fn() {
let t = buf.to_string()
if !t.is_empty() {
tokens.push(t)
buf.reset()
}
}
let mut i = 0
while i < view.length() {
let c = match view.get_char(i) {
Some(ch) => ch
None => ' '
}
if c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\u{000C}' || c == '\u{000B}' {
flush()
} else {
buf.write_char(c)
}
i = i + 1
}
flush()
if tokens.length() == 0 {
return ""
}
// Escape inner `"` by doubling, then wrap.
let out : Array[String] = []
for t in tokens {
let escaped = StringBuilder::new()
let tv = t.view()
let mut j = 0
while j < tv.length() {
let c = match tv.get_char(j) {
Some(ch) => ch
None => ' '
}
if c == '"' {
escaped.write_string("\"\"")
} else {
escaped.write_char(c)
}
j = j + 1
}
out.push("\"" + escaped.to_string() + "\"")
}
out.join(" ")
}
// ── inline tests ──
///|
test "sanitize_fts5_query: normal single word" {
assert_eq(sanitize_fts5_query("moonbit"), "\"moonbit\"")
}
///|
test "sanitize_fts5_query: multi-word wraps each token" {
assert_eq(sanitize_fts5_query("moonbit rocks"), "\"moonbit\" \"rocks\"")
}
///|
test "sanitize_fts5_query: special chars C++ and parens quoted" {
assert_eq(sanitize_fts5_query("C++ (fast)"), "\"C++\" \"(fast)\"")
}
///|
test "sanitize_fts5_query: inner quotes escaped by doubling" {
// say "hi" → "say" """hi"""
assert_eq(sanitize_fts5_query("say \"hi\""), "\"say\" \"\"\"hi\"\"\"")
}
///|
test "sanitize_fts5_query: empty string returns empty" {
assert_eq(sanitize_fts5_query(""), "")
}
///|
test "sanitize_fts5_query: whitespace-only returns empty" {
assert_eq(sanitize_fts5_query(" "), "")
}
///|
test "sanitize_fts5_query: unbalanced quote properly escaped" {
// foo" → "foo""
assert_eq(sanitize_fts5_query("foo\""), "\"foo\"\"\"")
}