Skip to content

Commit df46105

Browse files
authored
fix(ck-engine): parse lexical queries leniently instead of erroring on tantivy syntax (#166)
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 4023f99 commit df46105

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
@@ -901,9 +901,18 @@ async fn lexical_search(options: &SearchOptions) -> Result<Vec<SearchResult>> {
901901
let searcher = reader.searcher();
902902
let query_parser = QueryParser::for_index(&index, vec![content_field]);
903903

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

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

0 commit comments

Comments
 (0)