@@ -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