@@ -7,6 +7,10 @@ use rayon::prelude::*;
77use serde:: Deserialize ;
88
99use crate :: commands:: callgraph_store_adapter:: callers_result;
10+ use crate :: commands:: symbol_render:: {
11+ build_container_outline, might_have_container_members, render_symbol_within_budget,
12+ BudgetedSymbolRenderStatus ,
13+ } ;
1014use crate :: context:: { AppContext , SemanticIndexStatus } ;
1115use crate :: grep_executor:: { self , GrepParams } ;
1216use crate :: inspect:: job:: { is_test_file, is_test_support_file} ;
@@ -17,7 +21,7 @@ use crate::search_index::{
1721 sort_grep_matches_by_mtime_desc, GrepMatch , GrepResult , IndexStatus , SearchIndex ,
1822} ;
1923use crate :: semantic_index:: { is_onnx_runtime_unavailable, EmbeddingModel , SemanticResult } ;
20- use crate :: symbols:: SymbolKind ;
24+ use crate :: symbols:: { Range , Symbol , SymbolKind } ;
2125
2226const DEFAULT_TOP_K : usize = 10 ;
2327const MAX_TOP_K : usize = 100 ;
@@ -873,7 +877,8 @@ fn handle_semantic_or_hybrid_search(
873877 // Read display snippets from source on the fly (top 3 only, rank-budgeted)
874878 // so both the text rendering and the JSON `results` carry fresh, correctly
875879 // sized previews. Drives the conditional zoom hint.
876- let snippets_incomplete = enrich_snippets_from_source ( & mut results, project_root) ;
880+ let snippets_incomplete =
881+ enrich_snippets_from_source_with_context ( & mut results, project_root, Some ( ctx) ) ;
877882
878883 search_response (
879884 req,
@@ -2057,7 +2062,16 @@ fn snippet_line_budget(global_rank: usize) -> usize {
20572062/// summaries keep the generated summary (not source lines). Returns true when
20582063/// any snippet was truncated or omitted, so the caller emits the zoom hint only
20592064/// when it is actionable.
2065+ #[ cfg( test) ]
20602066fn enrich_snippets_from_source ( results : & mut [ HybridResult ] , project_root : & Path ) -> bool {
2067+ enrich_snippets_from_source_with_context ( results, project_root, None )
2068+ }
2069+
2070+ fn enrich_snippets_from_source_with_context (
2071+ results : & mut [ HybridResult ] ,
2072+ project_root : & Path ,
2073+ ctx : Option < & AppContext > ,
2074+ ) -> bool {
20612075 // Cache reads so two top-3 hits in the same file read it once.
20622076 let mut file_lines: HashMap < PathBuf , Option < Vec < String > > > = HashMap :: new ( ) ;
20632077 let mut incomplete = false ;
@@ -2098,22 +2112,20 @@ fn enrich_snippets_from_source(results: &mut [HybridResult], project_root: &Path
20982112 }
20992113
21002114 if should_expand_rank0_snippet ( rank, result, project_root) {
2101- // Render the symbol the way aft_zoom does: its exact body bounds plus
2102- // its leading doc comment / attributes / decorators, and nothing after
2103- // its end (no trailing neighbor bleed). This spares the agent a
2104- // follow-up zoom/read of a file it already saw here, so the cap is
2105- // generous; only a runaway giant falls through to the preview budget.
2106- let doc_start = doc_comment_start ( lines, start) ;
2107- if end. saturating_sub ( doc_start) <= RANK0_FULL_SNIPPET_MAX_LINES {
2108- // Complete symbol shown — tell the agent it can edit without a
2109- // re-read. Only on this full-expansion path, never the capped
2110- // preview below. Appended to the snippet (display-only) like the
2111- // "+N more lines" trailer, so it renders under the body.
2112- result. snippet = format ! (
2113- "{}\n {RANK0_FULL_SYMBOL_NOTICE}" ,
2114- lines[ doc_start..end] . join( "\n " )
2115- ) ;
2116- continue ;
2115+ let rendered = render_rank0_symbol_snippet ( result, lines, ctx) ;
2116+ match rendered. status {
2117+ BudgetedSymbolRenderStatus :: Complete => {
2118+ // Append the full-body notice only for Complete so callers know
2119+ // they received the entire symbol source. Skip it for Truncated
2120+ // or Menu results.
2121+ result. snippet = append_rank0_full_symbol_notice ( rendered. content ) ;
2122+ continue ;
2123+ }
2124+ BudgetedSymbolRenderStatus :: Truncated | BudgetedSymbolRenderStatus :: Menu => {
2125+ result. snippet = rendered. content ;
2126+ incomplete = true ;
2127+ continue ;
2128+ }
21172129 }
21182130 }
21192131
@@ -2134,69 +2146,73 @@ fn enrich_snippets_from_source(results: &mut [HybridResult], project_root: &Path
21342146 incomplete
21352147}
21362148
2137- /// Walk `start` (0-based index of the symbol's first body line) backwards over a
2138- /// contiguous block of leading doc-comment / attribute / decorator lines, so the
2139- /// rank-0 preview includes the symbol's doc the way aft_zoom does. Stops at the
2140- /// first blank line or non-comment/non-decorator line — i.e. the previous
2141- /// symbol's code — so it never bleeds a neighbor into the preview. Heuristic by
2142- /// line prefix to stay language-agnostic: `//` `///` `//!` (Rust/TS/JS/Go/…),
2143- /// `/*` `*` `*/` (block / JSDoc), Rust `#[attr]`/`#![...]`, `# ` comments
2144- /// (Python/Ruby/Bash), `--` (Lua/SQL), and `@` (TS/Java/Python decorators).
2145- fn doc_comment_start ( lines : & [ String ] , start : usize ) -> usize {
2146- let mut s = start;
2147- while s > 0 {
2148- let prev = lines[ s - 1 ] . trim_start ( ) ;
2149- let is_doc_or_attr = prev. starts_with ( "//" )
2150- || prev. starts_with ( "/*" )
2151- || prev. starts_with ( '*' )
2152- || is_hash_doc_or_attr ( prev)
2153- || prev. starts_with ( "--" )
2154- || prev. starts_with ( '@' ) ;
2155- if !is_doc_or_attr {
2156- break ;
2149+ fn render_rank0_symbol_snippet (
2150+ result : & HybridResult ,
2151+ lines : & [ String ] ,
2152+ ctx : Option < & AppContext > ,
2153+ ) -> crate :: commands:: symbol_render:: BudgetedSymbolRender {
2154+ let target = symbol_for_rank0_render ( result, ctx) . unwrap_or_else ( || symbol_from_result ( result) ) ;
2155+ let outline = ctx. and_then ( |ctx| {
2156+ if might_have_container_members ( & target) {
2157+ build_container_outline ( ctx, & result. file , & target) . ok ( )
2158+ } else {
2159+ None
21572160 }
2158- s -= 1 ;
2159- }
2160- s
2161+ } ) ;
2162+
2163+ render_symbol_within_budget (
2164+ & target,
2165+ lines,
2166+ crate :: parser:: detect_language ( & result. file ) ,
2167+ outline. as_ref ( ) ,
2168+ RANK0_FULL_SNIPPET_MAX_LINES ,
2169+ )
21612170}
21622171
2163- fn is_hash_doc_or_attr ( line : & str ) -> bool {
2164- if line. starts_with ( "#[" ) || line. starts_with ( "#![" ) {
2165- return true ;
2166- }
2172+ fn symbol_for_rank0_render ( ctx_result : & HybridResult , ctx : Option < & AppContext > ) -> Option < Symbol > {
2173+ let symbols = ctx?. provider ( ) . list_symbols ( & ctx_result. file ) . ok ( ) ?;
2174+ symbols
2175+ . iter ( )
2176+ . find ( |symbol| symbol_matches_result ( symbol, ctx_result, true ) )
2177+ . cloned ( )
2178+ . or_else ( || {
2179+ symbols
2180+ . into_iter ( )
2181+ . find ( |symbol| symbol_matches_result ( symbol, ctx_result, false ) )
2182+ } )
2183+ }
21672184
2168- let Some ( rest) = line. strip_prefix ( '#' ) else {
2169- return false ;
2170- } ;
2171- let Some ( first) = rest. chars ( ) . next ( ) else {
2172- return true ;
2173- } ;
2174- first. is_whitespace ( ) && !starts_with_c_preprocessor_directive ( rest. trim_start ( ) )
2185+ fn symbol_matches_result ( symbol : & Symbol , result : & HybridResult , exact_range : bool ) -> bool {
2186+ symbol. name == result. name
2187+ && symbol. kind == result. kind
2188+ && ( !exact_range
2189+ || ( symbol. range . start_line == result. start_line
2190+ && symbol. range . end_line == result. end_line ) )
21752191}
21762192
2177- fn starts_with_c_preprocessor_directive ( rest : & str ) -> bool {
2178- let directive = rest
2179- . split ( | ch : char | !ch . is_ascii_alphabetic ( ) )
2180- . next ( )
2181- . unwrap_or_default ( ) ;
2182- matches ! (
2183- directive ,
2184- "define"
2185- | "elif"
2186- | "else"
2187- | "endif"
2188- | "error"
2189- | "if"
2190- | "ifdef"
2191- | "ifndef"
2192- | "include"
2193- | "line"
2194- | "pragma"
2195- | "region"
2196- | "undef"
2197- | "using"
2198- | "warning"
2199- )
2193+ fn symbol_from_result ( result : & HybridResult ) -> Symbol {
2194+ Symbol {
2195+ name : result . name . clone ( ) ,
2196+ kind : result . kind . clone ( ) ,
2197+ range : Range {
2198+ start_line : result . start_line ,
2199+ start_col : 0 ,
2200+ end_line : result . end_line ,
2201+ end_col : 0 ,
2202+ } ,
2203+ signature : None ,
2204+ scope_chain : Vec :: new ( ) ,
2205+ exported : result . exported ,
2206+ parent : None ,
2207+ }
2208+ }
2209+
2210+ fn append_rank0_full_symbol_notice ( content : String ) -> String {
2211+ if content . is_empty ( ) {
2212+ RANK0_FULL_SYMBOL_NOTICE . to_string ( )
2213+ } else {
2214+ format ! ( "{content} \n {RANK0_FULL_SYMBOL_NOTICE}" )
2215+ }
22002216}
22012217
22022218fn should_expand_rank0_snippet ( rank : usize , result : & HybridResult , project_root : & Path ) -> bool {
@@ -3681,6 +3697,7 @@ mod tests {
36813697 "full rank-0 symbol should not need a zoom hint"
36823698 ) ;
36833699 assert ! ( results[ 0 ] . snippet. contains( "line29" ) ) ;
3700+ assert ! ( results[ 0 ] . snippet. contains( RANK0_FULL_SYMBOL_NOTICE ) ) ;
36843701 assert ! ( !results[ 0 ] . snippet. contains( "+10 more lines" ) ) ;
36853702 }
36863703
@@ -3719,9 +3736,69 @@ mod tests {
37193736 }
37203737
37213738 #[ test]
3722- fn oversized_rank0_full_expansion_falls_back_to_preview ( ) {
3723- // Larger than RANK0_FULL_SNIPPET_MAX_LINES (250) so full expansion is
3724- // declined and the line-budget preview kicks in.
3739+ fn rank0_large_container_renders_member_menu_without_full_notice ( ) {
3740+ let dir = tempfile:: tempdir ( ) . expect ( "tempdir" ) ;
3741+ let path = dir. path ( ) . join ( "large.ts" ) ;
3742+ let mut content = String :: from (
3743+ "class BigContainer {\n methodOne(): number {\n const visibleMethodBodyLine = 1;\n " ,
3744+ ) ;
3745+ for i in 0 ..155 {
3746+ content. push_str ( & format ! ( " const filler{i} = {i};\n " ) ) ;
3747+ }
3748+ content. push_str (
3749+ " return visibleMethodBodyLine;\n }\n \n methodTwo(): void {\n console.log(\" second\" );\n }\n }\n " ,
3750+ ) ;
3751+ std:: fs:: write ( & path, content) . expect ( "write large class" ) ;
3752+ let ctx = test_context ( dir. path ( ) ) ;
3753+ let symbols = ctx. provider ( ) . list_symbols ( & path) . expect ( "list symbols" ) ;
3754+ let target = symbols
3755+ . iter ( )
3756+ . find ( |symbol| symbol. name == "BigContainer" )
3757+ . expect ( "BigContainer symbol" ) ;
3758+ let mut results = vec ! [ HybridResult {
3759+ file: path,
3760+ name: "BigContainer" . to_string( ) ,
3761+ kind: SymbolKind :: Class ,
3762+ start_line: target. range. start_line,
3763+ end_line: target. range. end_line,
3764+ exported: false ,
3765+ snippet: String :: new( ) ,
3766+ score: 0.99 ,
3767+ source: "semantic" ,
3768+ semantic_score: Some ( 0.99 ) ,
3769+ lexical_score: None ,
3770+ hybrid_boosted: false ,
3771+ cap_protected: false ,
3772+ lexical_generated_artifact: false ,
3773+ } ] ;
3774+
3775+ let incomplete =
3776+ enrich_snippets_from_source_with_context ( & mut results, dir. path ( ) , Some ( & ctx) ) ;
3777+ let snippet = & results[ 0 ] . snippet ;
3778+
3779+ assert ! ( incomplete, "member menu is not a complete body" ) ;
3780+ assert ! (
3781+ snippet. contains( "member-signature menu; zoom a member for its body" ) ,
3782+ "large container should render a member menu: {snippet}"
3783+ ) ;
3784+ assert ! (
3785+ snippet. contains( "BigContainer.methodOne(): number" ) ,
3786+ "menu should include qualified method signatures: {snippet}"
3787+ ) ;
3788+ assert ! (
3789+ !snippet. contains( "visibleMethodBodyLine" ) ,
3790+ "menu must not include the class body: {snippet}"
3791+ ) ;
3792+ assert ! (
3793+ !snippet. contains( RANK0_FULL_SYMBOL_NOTICE ) ,
3794+ "member menu must not claim the full symbol was shown: {snippet}"
3795+ ) ;
3796+ }
3797+
3798+ #[ test]
3799+ fn oversized_rank0_full_expansion_renders_budgeted_head_slice ( ) {
3800+ // Use a 300-line symbol so the top result is truncated to a head slice,
3801+ // setting incomplete=true instead of using the small default preview.
37253802 let dir = tempfile:: tempdir ( ) . expect ( "tempdir" ) ;
37263803 let mut results = vec ! [ write_symbol_hit( dir. path( ) , "huge.rs" , "huge" , 300 ) ] ;
37273804 results[ 0 ] . semantic_score = Some ( 0.99 ) ;
@@ -3730,9 +3807,11 @@ mod tests {
37303807 let incomplete = enrich_snippets_from_source ( & mut results, dir. path ( ) ) ;
37313808
37323809 assert ! ( incomplete) ;
3733- assert ! ( results[ 0 ] . snippet. contains( "line19 " ) ) ;
3810+ assert ! ( results[ 0 ] . snippet. contains( "line249 " ) ) ;
37343811 assert ! ( !results[ 0 ] . snippet. contains( "line299" ) ) ;
3735- assert ! ( results[ 0 ] . snippet. contains( "+280 more lines" ) ) ;
3812+ assert ! ( results[ 0 ]
3813+ . snippet
3814+ . contains( "… +50 more lines — zoom huge for the full body" ) ) ;
37363815 assert ! (
37373816 !results[ 0 ] . snippet. contains( RANK0_FULL_SYMBOL_NOTICE ) ,
37383817 "capped fallback is incomplete — must NOT claim no-re-read"
0 commit comments