@@ -191,22 +191,68 @@ fn node_kind_to_symbol_kind(node_kind: &str, in_class: bool) -> SymbolKind {
191191// covers this with no further changes needed.
192192const PARSE_TIMEOUT_MICROS : u64 = 5_000_000 ;
193193
194+ /// Why `parse_tree` failed to produce a tree (Wave 5 item 5.7,
195+ /// docs/plans/2026-08-21-truth-kernel-hardening-wave5-residual-closure-plan.md)
196+ /// -- threaded as a `Result` instead of collapsing every cause into one
197+ /// `None`, so a caller that surfaces failure reasons (this module's own
198+ /// `extract_*` wrappers, `verify_live`, `insertion_hunk_for`) can say WHY a
199+ /// re-parse failed, not just THAT it did.
200+ #[ derive( Debug , Clone , Copy , PartialEq , Eq ) ]
201+ pub enum ParseFailure {
202+ /// `language` has no registered `LanguageSpec` at all, or the spec
203+ /// exists but its `ts_language` grammar loader returned `None` (the
204+ /// `lang-*` Cargo feature gating that grammar wasn't compiled into this
205+ /// build). The normal, expected outcome for any Tier-0.5 language --
206+ /// callers that fall back to shallow extraction on this variant are not
207+ /// handling an error, just routing around a grammar that was never
208+ /// supposed to exist here.
209+ UnsupportedLanguage ,
210+ /// `Parser::set_language` rejected the grammar -- an ABI version
211+ /// mismatch between the `tree-sitter` core crate and this grammar
212+ /// crate. Unlike `UnsupportedLanguage`, this would indicate a real
213+ /// dependency-version bug, not an expected per-language gap.
214+ AbiLoadFailed ,
215+ /// `Parser::parse` returned `None` -- the per-call timeout
216+ /// (`PARSE_TIMEOUT_MICROS`) was hit on a pathological/adversarial input.
217+ Timeout ,
218+ }
219+
220+ impl ParseFailure {
221+ pub fn as_str ( self ) -> & ' static str {
222+ match self {
223+ ParseFailure :: UnsupportedLanguage => "unsupported_language" ,
224+ ParseFailure :: AbiLoadFailed => "abi_load_failed" ,
225+ ParseFailure :: Timeout => "timeout" ,
226+ }
227+ }
228+ }
229+
230+ impl std:: fmt:: Display for ParseFailure {
231+ fn fmt ( & self , f : & mut std:: fmt:: Formatter < ' _ > ) -> std:: fmt:: Result {
232+ f. write_str ( self . as_str ( ) )
233+ }
234+ }
235+
194236/// Parse `source` for a tier-0 `language` into a tree-sitter tree, or `None` if
195237/// the language is unsupported or parsing fails. Single source of the per-language
196238/// grammar mapping.
197- pub fn parse_tree ( source : & str , language : & str ) -> Option < tree_sitter:: Tree > {
198- let lang = ( crate :: indexer:: lang_constants:: find_spec ( language) ?. ts_language ) ( ) ?;
239+ pub fn parse_tree ( source : & str , language : & str ) -> Result < tree_sitter:: Tree , ParseFailure > {
240+ let spec = crate :: indexer:: lang_constants:: find_spec ( language)
241+ . ok_or ( ParseFailure :: UnsupportedLanguage ) ?;
242+ let lang = ( spec. ts_language ) ( ) . ok_or ( ParseFailure :: UnsupportedLanguage ) ?;
199243 let mut parser = tree_sitter:: Parser :: new ( ) ;
200- parser. set_language ( & lang) . ok ( ) ?;
244+ parser
245+ . set_language ( & lang)
246+ . map_err ( |_| ParseFailure :: AbiLoadFailed ) ?;
201247 parser. set_timeout_micros ( PARSE_TIMEOUT_MICROS ) ;
202- parser. parse ( source, None )
248+ parser. parse ( source, None ) . ok_or ( ParseFailure :: Timeout )
203249}
204250pub fn extract_symbols (
205251 source : & str ,
206252 language : & str ,
207253 path : & str ,
208254) -> Result < Vec < ParsedSymbol > , String > {
209- let tree = parse_tree ( source, language) . ok_or ( "Failed to parse" ) ?;
255+ let tree = parse_tree ( source, language) . map_err ( |e| e . to_string ( ) ) ?;
210256 Ok ( extract_symbols_from_tree ( & tree, source, language, path) )
211257}
212258
@@ -1968,7 +2014,7 @@ fn selected_callee_byte_span(
19682014/// Extract call sites from a source file, each attributed to its enclosing function.
19692015/// Top-level calls (outside any function) are skipped — they have no caller symbol.
19702016pub fn extract_calls ( source : & str , language : & str , _path : & str ) -> Result < Vec < RawCall > , String > {
1971- let tree = parse_tree ( source, language) . ok_or ( "Failed to parse" ) ?;
2017+ let tree = parse_tree ( source, language) . map_err ( |e| e . to_string ( ) ) ?;
19722018 Ok ( extract_calls_from_tree ( & tree, source, language) )
19732019}
19742020
@@ -2031,7 +2077,7 @@ pub fn extract_file_aliases(
20312077 language : & str ,
20322078 ctx : & crate :: resolver:: FileContext ,
20332079) -> std:: collections:: HashMap < String , String > {
2034- let Some ( tree) = parse_tree ( source, language) else {
2080+ let Ok ( tree) = parse_tree ( source, language) else {
20352081 return std:: collections:: HashMap :: new ( ) ;
20362082 } ;
20372083 extract_file_aliases_from_tree ( & tree, source, language, ctx)
@@ -2060,7 +2106,7 @@ pub fn extract_file_aliases_from_tree(
20602106/// JavaScript (dynamic) yields an empty map. Go shares one type across several
20612107/// names (`x, y Foo`), so each name is mapped.
20622108pub fn extract_type_map ( source : & str , language : & str ) -> std:: collections:: HashMap < String , String > {
2063- let Some ( tree) = parse_tree ( source, language) else {
2109+ let Ok ( tree) = parse_tree ( source, language) else {
20642110 return std:: collections:: HashMap :: new ( ) ;
20652111 } ;
20662112 extract_type_map_from_tree ( & tree, source, language)
@@ -4259,43 +4305,98 @@ public class FooTest {
42594305 #[ test]
42604306 #[ cfg( feature = "lang-ruby" ) ]
42614307 fn test_tier0_5_grammar_loads_ruby ( ) {
4262- assert ! ( parse_tree( "def foo\n end\n " , "ruby" ) . is_some ( ) ) ;
4308+ assert ! ( parse_tree( "def foo\n end\n " , "ruby" ) . is_ok ( ) ) ;
42634309 }
42644310
42654311 #[ test]
42664312 #[ cfg( feature = "lang-php" ) ]
42674313 fn test_tier0_5_grammar_loads_php ( ) {
4268- assert ! ( parse_tree( "<?php\n function foo() {}\n " , "php" ) . is_some ( ) ) ;
4314+ assert ! ( parse_tree( "<?php\n function foo() {}\n " , "php" ) . is_ok ( ) ) ;
42694315 }
42704316
42714317 #[ test]
42724318 #[ cfg( feature = "lang-csharp" ) ]
42734319 fn test_tier0_5_grammar_loads_csharp ( ) {
4274- assert ! ( parse_tree( "class Foo { void Bar() {} }\n " , "csharp" ) . is_some ( ) ) ;
4320+ assert ! ( parse_tree( "class Foo { void Bar() {} }\n " , "csharp" ) . is_ok ( ) ) ;
42754321 }
42764322
42774323 #[ test]
42784324 #[ cfg( feature = "lang-shell" ) ]
42794325 fn test_tier0_5_grammar_loads_shell ( ) {
4280- assert ! ( parse_tree( "foo() {\n echo hi\n }\n " , "shell" ) . is_some ( ) ) ;
4326+ assert ! ( parse_tree( "foo() {\n echo hi\n }\n " , "shell" ) . is_ok ( ) ) ;
42814327 }
42824328
42834329 #[ test]
42844330 #[ cfg( feature = "lang-c" ) ]
42854331 fn test_tier0_5_grammar_loads_c ( ) {
4286- assert ! ( parse_tree( "int foo() { return 0; }\n " , "c" ) . is_some ( ) ) ;
4332+ assert ! ( parse_tree( "int foo() { return 0; }\n " , "c" ) . is_ok ( ) ) ;
42874333 }
42884334
42894335 #[ test]
42904336 #[ cfg( feature = "lang-cpp" ) ]
42914337 fn test_tier0_5_grammar_loads_cpp ( ) {
4292- assert ! ( parse_tree( "int foo() { return 0; }\n " , "cpp" ) . is_some ( ) ) ;
4338+ assert ! ( parse_tree( "int foo() { return 0; }\n " , "cpp" ) . is_ok ( ) ) ;
42934339 }
42944340
42954341 #[ test]
42964342 #[ cfg( feature = "lang-r" ) ]
42974343 fn test_tier0_5_grammar_loads_r ( ) {
4298- assert ! ( parse_tree( "foo <- function(x) {\n x\n }\n " , "r" ) . is_some( ) ) ;
4344+ assert ! ( parse_tree( "foo <- function(x) {\n x\n }\n " , "r" ) . is_ok( ) ) ;
4345+ }
4346+
4347+ // Wave 5 item 5.7 (docs/plans/2026-08-21-truth-kernel-hardening-wave5-
4348+ // residual-closure-plan.md): parse_tree's ParseFailure reason must be
4349+ // the correct variant for each real failure mode, and its `as_str`/
4350+ // `Display` output is load-bearing -- verify_live/insertion_hunk_for
4351+ // interpolate it directly into an agent-facing error message via `{e}`.
4352+ #[ test]
4353+ fn parse_tree_unrecognized_language_name_reports_unsupported_language ( ) {
4354+ // tree_sitter::Tree implements neither PartialEq nor Debug, so
4355+ // assert_eq!/unwrap_err() can't be used directly on this Result --
4356+ // matches! sidesteps both trait requirements.
4357+ assert ! ( matches!(
4358+ parse_tree( "fn main() {}" , "not-a-real-language" ) ,
4359+ Err ( ParseFailure :: UnsupportedLanguage )
4360+ ) ) ;
4361+ }
4362+
4363+ #[ test]
4364+ fn parse_tree_valid_source_and_supported_language_succeeds ( ) {
4365+ assert ! ( parse_tree( "fn main() {}" , "rust" ) . is_ok( ) ) ;
4366+ }
4367+
4368+ #[ test]
4369+ fn parse_failure_as_str_and_display_match_for_every_variant ( ) {
4370+ for variant in [
4371+ ParseFailure :: UnsupportedLanguage ,
4372+ ParseFailure :: AbiLoadFailed ,
4373+ ParseFailure :: Timeout ,
4374+ ] {
4375+ assert_eq ! ( variant. to_string( ) , variant. as_str( ) ) ;
4376+ assert ! ( !variant. as_str( ) . is_empty( ) ) ;
4377+ }
4378+ // Exact strings are part of the public contract now that
4379+ // extract_symbols/extract_calls thread `.to_string()` of this type
4380+ // straight into REPARSE_FAILED/STALE_SYMBOL error messages shown to
4381+ // an agent -- a silent rename here would silently change those.
4382+ assert_eq ! (
4383+ ParseFailure :: UnsupportedLanguage . as_str( ) ,
4384+ "unsupported_language"
4385+ ) ;
4386+ assert_eq ! ( ParseFailure :: AbiLoadFailed . as_str( ) , "abi_load_failed" ) ;
4387+ assert_eq ! ( ParseFailure :: Timeout . as_str( ) , "timeout" ) ;
4388+ }
4389+
4390+ #[ test]
4391+ fn extract_symbols_unsupported_language_error_names_the_real_reason_not_a_generic_string ( ) {
4392+ // Regression guard for the exact improvement Wave 5.7 makes over the
4393+ // old `.ok_or("Failed to parse")?`: the error string a caller like
4394+ // verify_live surfaces must now say WHY, not just THAT parsing failed.
4395+ // `.err()` (not `.unwrap_err()`) is used deliberately: ParsedSymbol
4396+ // has no Debug impl, and unwrap_err() requires the Ok side (here
4397+ // Vec<ParsedSymbol>) to implement it too.
4398+ let err = extract_symbols ( "fn main() {}" , "not-a-real-language" , "a.rs" ) . err ( ) ;
4399+ assert_eq ! ( err, Some ( "unsupported_language" . to_string( ) ) ) ;
42994400 }
43004401
43014402 /// Regression test for the shell-specific bug this guard suite was born
@@ -4563,7 +4664,7 @@ public class FooTest {
45634664 ) ;
45644665 // Markdown has no tree-sitter grammar, ever — extract_file_data's
45654666 // dedicated branch is the only path that can produce symbols for it.
4566- assert ! ( parse_tree( "# x" , "markdown" ) . is_none ( ) ) ;
4667+ assert ! ( parse_tree( "# x" , "markdown" ) . is_err ( ) ) ;
45674668 }
45684669 #[ test]
45694670 fn test_shallow_kotlin ( ) {
@@ -4595,7 +4696,7 @@ public class FooTest {
45954696 #[ cfg( feature = "lang-kotlin" ) ]
45964697 fn test_tier0_5_grammar_loads_kotlin ( ) {
45974698 assert ! (
4598- parse_tree( "fun main() {}" , "kotlin" ) . is_some ( ) ,
4699+ parse_tree( "fun main() {}" , "kotlin" ) . is_ok ( ) ,
45994700 "tree-sitter-kotlin-ng grammar should load and parse"
46004701 ) ;
46014702 }
@@ -4604,7 +4705,7 @@ public class FooTest {
46044705 #[ cfg( feature = "lang-swift" ) ]
46054706 fn test_tier0_5_grammar_loads_swift ( ) {
46064707 assert ! (
4607- parse_tree( "func main() {}" , "swift" ) . is_some ( ) ,
4708+ parse_tree( "func main() {}" , "swift" ) . is_ok ( ) ,
46084709 "tree-sitter-swift grammar should load and parse"
46094710 ) ;
46104711 }
@@ -4696,7 +4797,7 @@ public class FooTest {
46964797 #[ cfg( feature = "lang-scala" ) ]
46974798 fn test_tier0_5_grammar_loads_scala ( ) {
46984799 assert ! (
4699- parse_tree( "def main(): Unit = {}" , "scala" ) . is_some ( ) ,
4800+ parse_tree( "def main(): Unit = {}" , "scala" ) . is_ok ( ) ,
47004801 "tree-sitter-scala grammar should load and parse — locks the pinned ABI (=0.24.1, ABI 14); \
47014802 a caret-range bump to 0.25.0+ (ABI 15) would silently regress this to shallow line-scan"
47024803 ) ;
@@ -5168,7 +5269,7 @@ class Foo {
51685269 #[ cfg( feature = "lang-dart" ) ]
51695270 fn test_tier0_5_grammar_loads_dart ( ) {
51705271 assert ! (
5171- parse_tree( "void main() {}" , "dart" ) . is_some ( ) ,
5272+ parse_tree( "void main() {}" , "dart" ) . is_ok ( ) ,
51725273 "tree-sitter-dart grammar should load and parse — locks the pinned ABI \
51735274 (=0.0.4, ABI 14, the newest ABI-14 release since this grammar has no git \
51745275 tags — verified by downloading each published .crate tarball and grepping \
@@ -5283,7 +5384,7 @@ class Foo {
52835384 #[ cfg( feature = "lang-lua" ) ]
52845385 fn test_tier0_5_grammar_loads_lua ( ) {
52855386 assert ! (
5286- parse_tree( "function main() end" , "lua" ) . is_some ( ) ,
5387+ parse_tree( "function main() end" , "lua" ) . is_ok ( ) ,
52875388 "tree-sitter-lua grammar should load and parse — locks the pinned ABI \
52885389 (=0.2.0, ABI 14); a caret-range bump to 0.4.1+ (ABI 15) would silently \
52895390 regress this to shallow line-scan"
@@ -5343,7 +5444,7 @@ class Foo {
53435444 #[ cfg( feature = "lang-elixir" ) ]
53445445 fn test_tier0_5_grammar_loads_elixir ( ) {
53455446 assert ! (
5346- parse_tree( "def main do\n end\n " , "elixir" ) . is_some ( ) ,
5447+ parse_tree( "def main do\n end\n " , "elixir" ) . is_ok ( ) ,
53475448 "tree-sitter-elixir grammar should load and parse — locks the pinned ABI \
53485449 (=0.3.5, ABI 14)"
53495450 ) ;
@@ -5434,7 +5535,7 @@ class Foo {
54345535 #[ cfg( feature = "lang-haskell" ) ]
54355536 fn test_tier0_5_grammar_loads_haskell ( ) {
54365537 assert ! (
5437- parse_tree( "main :: IO ()\n main = putStrLn \" hi\" \n " , "haskell" ) . is_some ( ) ,
5538+ parse_tree( "main :: IO ()\n main = putStrLn \" hi\" \n " , "haskell" ) . is_ok ( ) ,
54385539 "tree-sitter-haskell grammar should load and parse — locks the pinned ABI \
54395540 (=0.23.1, ABI 14)"
54405541 ) ;
@@ -5512,7 +5613,7 @@ class Foo {
55125613 #[ cfg( feature = "lang-ocaml" ) ]
55135614 fn test_tier0_5_grammar_loads_ocaml ( ) {
55145615 assert ! (
5515- parse_tree( "let main () = print_string \" hi\" \n " , "ocaml" ) . is_some ( ) ,
5616+ parse_tree( "let main () = print_string \" hi\" \n " , "ocaml" ) . is_ok ( ) ,
55165617 "tree-sitter-ocaml grammar should load and parse — locks the pinned ABI \
55175618 (=0.24.2, ABI 14); a caret-range bump to 0.25.0+ (ABI 15) would silently \
55185619 regress this to shallow line-scan"
@@ -5585,7 +5686,7 @@ class Foo {
55855686 #[ cfg( feature = "lang-zig" ) ]
55865687 fn test_tier0_5_grammar_loads_zig ( ) {
55875688 assert ! (
5588- parse_tree( "pub fn main() void {}\n " , "zig" ) . is_some ( ) ,
5689+ parse_tree( "pub fn main() void {}\n " , "zig" ) . is_ok ( ) ,
55895690 "tree-sitter-zig grammar should load and parse — locks the pinned ABI \
55905691 (=1.1.2, ABI 14)"
55915692 ) ;
@@ -5664,7 +5765,7 @@ class Foo {
56645765 #[ cfg( feature = "lang-powershell" ) ]
56655766 fn test_tier0_5_grammar_loads_powershell ( ) {
56665767 assert ! (
5667- parse_tree( "function Main {\n Write-Host \" hi\" \n }\n " , "powershell" ) . is_some ( ) ,
5768+ parse_tree( "function Main {\n Write-Host \" hi\" \n }\n " , "powershell" ) . is_ok ( ) ,
56685769 "tree-sitter-powershell grammar should load and parse — locks the pinned ABI \
56695770 (=0.25.9, ABI 14); a caret-range bump to 0.25.10+ (ABI 15) would silently \
56705771 regress this to shallow line-scan"
@@ -5739,7 +5840,7 @@ class Foo {
57395840 #[ cfg( feature = "lang-groovy" ) ]
57405841 fn test_tier0_5_grammar_loads_groovy ( ) {
57415842 assert ! (
5742- parse_tree( "def main() {}\n " , "groovy" ) . is_some ( ) ,
5843+ parse_tree( "def main() {}\n " , "groovy" ) . is_ok ( ) ,
57435844 "tree-sitter-groovy grammar should load and parse — locks the pinned ABI \
57445845 (=0.1.2, ABI 14)"
57455846 ) ;
0 commit comments