Skip to content

Commit 7100df4

Browse files
committed
fix(ck-engine): parse lexical queries leniently instead of erroring on tantivy syntax
ck is grep-shaped, so lexical (--lex) queries arrive as arbitrary strings — from shell one-liners, scripts, and AI agents — that were never meant to be tantivy query syntax. QueryParser::parse_query rejects the whole search when the input contains anything it can't fully interpret: an unbalanced quote, a stray `field:` colon, a bare AND/OR, a leading wildcard. The terms that could still match are discarded along with the syntax error, and callers are forced to sanitize queries before handing them over. Switch lexical_search to QueryParser::parse_query_lenient: the interpretable terms are kept and the un-parseable fragments are dropped, with the parser's errors logged at debug level rather than propagated. A query that already parses cleanly yields the same query object, so its results and scores are unchanged (covered by an invariance test comparing strict and lenient parses); a query where every fragment errors degrades to normal empty results instead of a hard failure.
1 parent d2cfe11 commit 7100df4

1 file changed

Lines changed: 136 additions & 3 deletions

File tree

ck-engine/src/lib.rs

Lines changed: 136 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -898,9 +898,18 @@ async fn lexical_search(options: &SearchOptions) -> Result<Vec<SearchResult>> {
898898
let searcher = reader.searcher();
899899
let query_parser = QueryParser::for_index(&index, vec![content_field]);
900900

901-
let query = query_parser
902-
.parse_query(&options.query)
903-
.map_err(|e| CkError::Search(format!("Failed to parse query: {e}")))?;
901+
// Parse leniently so any string is a valid query: syntax tantivy can't
902+
// interpret (unbalanced quotes, stray field colons, bare boolean operators)
903+
// degrades to the terms it can parse instead of erroring. A query that
904+
// already parses cleanly yields the same query object with no errors, so
905+
// its results and scores are unchanged.
906+
let (query, parse_errors) = query_parser.parse_query_lenient(&options.query);
907+
for error in &parse_errors {
908+
tracing::debug!(
909+
"lenient parse of lexical query {:?}: {error:?}",
910+
options.query
911+
);
912+
}
904913

905914
let top_docs = if let Some(top_k) = options.top_k {
906915
searcher.search(&query, &TopDocs::with_limit(top_k))?
@@ -2264,4 +2273,128 @@ mod tests {
22642273
results.iter().map(|r| &r.file).collect::<Vec<_>>()
22652274
);
22662275
}
2276+
2277+
#[test]
2278+
fn test_lenient_parse_matches_strict_for_valid_query() {
2279+
// Invariance: a query that already parses cleanly yields the same
2280+
// documents and scores whether parsed strictly or leniently.
2281+
let mut schema_builder = Schema::builder();
2282+
let content_field = schema_builder.add_text_field("content", TEXT | STORED);
2283+
let schema = schema_builder.build();
2284+
let index = Index::create_in_ram(schema);
2285+
{
2286+
let mut writer = index.writer(50_000_000).unwrap();
2287+
writer
2288+
.add_document(doc!(content_field => "zebra alpha"))
2289+
.unwrap();
2290+
writer
2291+
.add_document(doc!(content_field => "zebra zebra beta"))
2292+
.unwrap();
2293+
writer
2294+
.add_document(doc!(content_field => "gamma delta"))
2295+
.unwrap();
2296+
writer.commit().unwrap();
2297+
}
2298+
let searcher = index.reader().unwrap().searcher();
2299+
let query_parser = QueryParser::for_index(&index, vec![content_field]);
2300+
2301+
let strict = query_parser.parse_query("zebra").unwrap();
2302+
let (lenient, errors) = query_parser.parse_query_lenient("zebra");
2303+
assert!(errors.is_empty(), "valid query must parse without errors");
2304+
2305+
let strict_hits = searcher.search(&strict, &TopDocs::with_limit(10)).unwrap();
2306+
let lenient_hits = searcher.search(&lenient, &TopDocs::with_limit(10)).unwrap();
2307+
assert_eq!(strict_hits.len(), lenient_hits.len());
2308+
for ((s_score, s_addr), (l_score, l_addr)) in strict_hits.iter().zip(lenient_hits.iter()) {
2309+
assert_eq!(s_addr, l_addr, "same documents in the same order");
2310+
assert!((s_score - l_score).abs() < 1e-6, "same scores");
2311+
}
2312+
}
2313+
2314+
#[tokio::test]
2315+
async fn test_lexical_search_recovers_from_unbalanced_quote() {
2316+
// parse_query hard-errors on an unbalanced quote; lenient parsing
2317+
// recovers the bare term and still matches.
2318+
let temp_dir = TempDir::new().unwrap();
2319+
fs::write(
2320+
temp_dir.path().join("mod.py"),
2321+
"def gamma():\n zebra = 3\n",
2322+
)
2323+
.unwrap();
2324+
fs::create_dir_all(temp_dir.path().join(".ck")).unwrap();
2325+
2326+
let options = SearchOptions {
2327+
mode: SearchMode::Lexical,
2328+
query: "zebra\"".to_string(),
2329+
path: temp_dir.path().to_path_buf(),
2330+
recursive: true,
2331+
..Default::default()
2332+
};
2333+
2334+
let results = lexical_search(&options)
2335+
.await
2336+
.expect("lenient parse must not error on an unbalanced quote");
2337+
assert!(
2338+
results
2339+
.iter()
2340+
.any(|r| r.file.file_name().unwrap() == "mod.py")
2341+
);
2342+
}
2343+
2344+
#[tokio::test]
2345+
async fn test_lexical_search_recovers_term_from_field_colon() {
2346+
// A clause referencing an unknown field is dropped; the bare term still
2347+
// matches, where parse_query would have failed the whole query.
2348+
let temp_dir = TempDir::new().unwrap();
2349+
fs::write(
2350+
temp_dir.path().join("mod.py"),
2351+
"def gamma():\n zebra = 3\n",
2352+
)
2353+
.unwrap();
2354+
fs::create_dir_all(temp_dir.path().join(".ck")).unwrap();
2355+
2356+
let options = SearchOptions {
2357+
mode: SearchMode::Lexical,
2358+
query: "zebra foo:baz".to_string(),
2359+
path: temp_dir.path().to_path_buf(),
2360+
recursive: true,
2361+
..Default::default()
2362+
};
2363+
2364+
let results = lexical_search(&options)
2365+
.await
2366+
.expect("lenient parse must not error on a stray field colon");
2367+
assert!(
2368+
results
2369+
.iter()
2370+
.any(|r| r.file.file_name().unwrap() == "mod.py")
2371+
);
2372+
}
2373+
2374+
#[tokio::test]
2375+
async fn test_lexical_search_all_fragments_error_degrades_gracefully() {
2376+
// Every fragment references an unknown field, so nothing is
2377+
// interpretable. parse_query would error; lenient parsing yields normal
2378+
// results (here empty) instead.
2379+
let temp_dir = TempDir::new().unwrap();
2380+
fs::write(
2381+
temp_dir.path().join("mod.py"),
2382+
"def gamma():\n zebra = 3\n",
2383+
)
2384+
.unwrap();
2385+
fs::create_dir_all(temp_dir.path().join(".ck")).unwrap();
2386+
2387+
let options = SearchOptions {
2388+
mode: SearchMode::Lexical,
2389+
query: "title:zebra".to_string(),
2390+
path: temp_dir.path().to_path_buf(),
2391+
recursive: true,
2392+
..Default::default()
2393+
};
2394+
2395+
assert!(
2396+
lexical_search(&options).await.is_ok(),
2397+
"an all-unknown-field query must degrade gracefully, not error"
2398+
);
2399+
}
22672400
}

0 commit comments

Comments
 (0)