Skip to content

Commit 5da4e8c

Browse files
ualtinokalfonso-aft
andcommitted
mason: dedupe zoom calls and cap search expansion
Co-authored-by: Alfonso <289616620+alfonso-aft@users.noreply.github.com>
1 parent 9f61c02 commit 5da4e8c

8 files changed

Lines changed: 668 additions & 286 deletions

File tree

crates/aft/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ pub mod restore_checkpoint;
4747
pub mod semantic_search;
4848
pub mod state;
4949
pub mod status;
50+
pub mod symbol_render;
5051
pub mod trace_data;
5152
pub mod trace_to;
5253
pub mod trace_to_symbol;

crates/aft/src/commands/semantic_search.rs

Lines changed: 159 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ use rayon::prelude::*;
77
use serde::Deserialize;
88

99
use 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+
};
1014
use crate::context::{AppContext, SemanticIndexStatus};
1115
use crate::grep_executor::{self, GrepParams};
1216
use 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
};
1923
use crate::semantic_index::{is_onnx_runtime_unavailable, EmbeddingModel, SemanticResult};
20-
use crate::symbols::SymbolKind;
24+
use crate::symbols::{Range, Symbol, SymbolKind};
2125

2226
const DEFAULT_TOP_K: usize = 10;
2327
const 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)]
20602066
fn 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

22022218
fn 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

Comments
 (0)