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