use std::path::Path;
use serde::Serialize;
use crate::context::AppContext;
use crate::protocol::{RawRequest, Response};
use crate::symbols::{Range, Symbol};
#[derive(Debug, Clone, Serialize)]
pub struct OutlineEntry {
pub name: String,
pub kind: String,
pub range: Range,
#[serde(skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
pub exported: bool,
pub members: Vec<OutlineEntry>,
}
pub fn handle_outline(req: &RawRequest, ctx: &AppContext) -> Response {
const MAX_OUTPUT_BYTES: usize = 30 * 1024;
if let Some(files_arr) = req.params.get("files").and_then(|v| v.as_array()) {
let project_root = ctx.config().project_root.clone();
let mut file_outlines: Vec<FileOutline> = Vec::with_capacity(files_arr.len());
let total_files_requested = files_arr.len();
for file_val in files_arr {
let file = match file_val.as_str() {
Some(f) => f,
None => continue,
};
let path = match ctx.validate_path(&req.id, Path::new(file)) {
Ok(path) => path,
Err(resp) => return resp,
};
if !path.exists() {
continue;
}
match ctx.provider().list_symbols(&path) {
Ok(symbols) => {
let entries = build_outline_tree(&symbols);
let rel_path = project_root
.as_ref()
.and_then(|root| path.strip_prefix(root).ok())
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| file.to_string());
file_outlines.push(FileOutline {
path: rel_path,
entries,
});
}
Err(_) => continue,
}
}
let text = format_multi_file_tree(&file_outlines, MAX_OUTPUT_BYTES, total_files_requested);
return Response::success(&req.id, serde_json::json!({ "text": text }));
}
let file = match req.params.get("file").and_then(|v| v.as_str()) {
Some(f) => f,
None => {
return Response::error(
&req.id,
"invalid_request",
"outline: missing required param 'file' or 'files'",
);
}
};
let path = match ctx.validate_path(&req.id, Path::new(file)) {
Ok(path) => path,
Err(resp) => return resp,
};
if !path.exists() {
return Response::error(
&req.id,
"file_not_found",
format!("file not found: {}", file),
);
}
let symbols = match ctx.provider().list_symbols(&path) {
Ok(s) => s,
Err(e) => {
return Response::error(&req.id, e.code(), e.to_string());
}
};
let entries = build_outline_tree(&symbols);
let filename = path
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_else(|| file.to_string());
let text = format_single_file_tree(&filename, &entries);
Response::success(&req.id, serde_json::json!({ "text": text }))
}
fn build_outline_tree(symbols: &[Symbol]) -> Vec<OutlineEntry> {
let mut top_level: Vec<OutlineEntry> = Vec::new();
let mut children: Vec<&Symbol> = Vec::new();
for sym in symbols {
if sym.parent.is_none() {
top_level.push(symbol_to_entry(sym));
} else {
children.push(sym);
}
}
for child in &children {
let entry = symbol_to_entry(child);
let scope = &child.scope_chain;
if scope.is_empty() {
top_level.push(entry);
continue;
}
if !insert_at_scope(&mut top_level, scope, entry.clone()) {
top_level.push(entry);
}
}
top_level
}
fn insert_at_scope(
entries: &mut Vec<OutlineEntry>,
scope_chain: &[String],
entry: OutlineEntry,
) -> bool {
if scope_chain.is_empty() {
return false;
}
let target_name = &scope_chain[0];
for existing in entries.iter_mut() {
if existing.name == *target_name {
if scope_chain.len() == 1 {
existing.members.push(entry);
return true;
} else {
return insert_at_scope(&mut existing.members, &scope_chain[1..], entry);
}
}
}
false
}
struct FileOutline {
path: String, entries: Vec<OutlineEntry>,
}
fn kind_abbrev(kind: &str) -> &str {
match kind {
"function" => "fn",
"variable" => "var",
"class" => "cls",
"interface" => "ifc",
"type_alias" => "type",
"enum" => "enum",
"method" => "mth",
"property" => "prop",
"struct" => "st",
"heading" => "h",
_ => &kind[..kind.len().min(4)],
}
}
fn format_entry_compact(entry: &OutlineEntry) -> String {
let vis = if entry.exported { 'E' } else { '-' };
let kind = kind_abbrev(&entry.kind);
let sl = entry.range.start_line + 1;
let el = entry.range.end_line + 1;
format!("{} {:<4} {} {}:{}", vis, kind, entry.name, sl, el)
}
fn format_entry_with_sig(entry: &OutlineEntry) -> String {
let vis = if entry.exported { 'E' } else { '-' };
let kind = kind_abbrev(&entry.kind);
let sl = entry.range.start_line + 1;
let el = entry.range.end_line + 1;
if let Some(ref sig) = entry.signature {
format!("{} {:<4} {} {}:{}", vis, kind, sig, sl, el)
} else {
format!("{} {:<4} {} {}:{}", vis, kind, entry.name, sl, el)
}
}
fn render_entries(entries: &[OutlineEntry], indent: usize, output: &mut String, with_sig: bool) {
let prefix = " ".repeat(indent);
let member_prefix = " ".repeat(indent + 1);
for entry in entries {
if with_sig {
output.push_str(&format!("{}{}\n", prefix, format_entry_with_sig(entry)));
} else {
output.push_str(&format!("{}{}\n", prefix, format_entry_compact(entry)));
}
if !entry.members.is_empty() {
for member in &entry.members {
if with_sig {
output.push_str(&format!(
"{}.{}\n",
member_prefix,
format_entry_with_sig(member)
));
} else {
output.push_str(&format!(
"{}.{}\n",
member_prefix,
format_entry_compact(member)
));
}
if !member.members.is_empty() {
render_entries(&member.members, indent + 2, output, with_sig);
}
}
}
}
}
fn format_single_file_tree(filename: &str, entries: &[OutlineEntry]) -> String {
let mut output = format!("{}\n", filename);
render_entries(entries, 1, &mut output, true);
output
}
fn format_multi_file_tree(
file_outlines: &[FileOutline],
max_bytes: usize,
total_requested: usize,
) -> String {
let mut output = String::new();
let mut truncated = false;
let mut files_shown = 0;
let mut sorted: Vec<&FileOutline> = file_outlines.iter().collect();
sorted.sort_by(|a, b| a.path.cmp(&b.path));
let mut prev_parts: Vec<&str> = Vec::new();
for fo in &sorted {
let parts: Vec<&str> = fo.path.split('/').collect();
let file_name = parts.last().copied().unwrap_or(&fo.path);
let dir_parts = &parts[..parts.len().saturating_sub(1)];
let common = prev_parts
.iter()
.zip(dir_parts.iter())
.take_while(|(a, b)| a == b)
.count();
for (i, part) in dir_parts.iter().enumerate().skip(common) {
let indent = " ".repeat(i);
output.push_str(&format!("{}{}/\n", indent, part));
}
let file_indent = " ".repeat(dir_parts.len());
output.push_str(&format!("{}{}\n", file_indent, file_name));
render_entries(&fo.entries, dir_parts.len() + 1, &mut output, false);
files_shown += 1;
prev_parts = parts.iter().map(|s| *s).collect();
if output.len() > max_bytes {
truncated = true;
break;
}
}
if truncated {
output.push_str(&format!(
"\n... truncated ({}/{} files shown, {}KB limit)\n\
Narrow scope with a more specific directory path or use filePath for single files.\n",
files_shown,
total_requested,
max_bytes / 1024,
));
}
output
}
fn symbol_to_entry(sym: &Symbol) -> OutlineEntry {
OutlineEntry {
name: sym.name.clone(),
kind: serde_json::to_value(&sym.kind)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_else(|| format!("{:?}", sym.kind).to_lowercase()),
range: sym.range.clone(),
signature: sym.signature.clone(),
exported: sym.exported,
members: Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::symbols::SymbolKind;
fn make_symbol(
name: &str,
kind: SymbolKind,
parent: Option<&str>,
scope_chain: Vec<&str>,
exported: bool,
) -> Symbol {
Symbol {
name: name.to_string(),
kind,
range: Range {
start_line: 0,
start_col: 0,
end_line: 0,
end_col: 0,
},
signature: None,
scope_chain: scope_chain.into_iter().map(String::from).collect(),
exported,
parent: parent.map(String::from),
}
}
#[test]
fn flat_symbols_stay_flat() {
let symbols = vec![
make_symbol("greet", SymbolKind::Function, None, vec![], true),
make_symbol("Config", SymbolKind::Interface, None, vec![], true),
];
let tree = build_outline_tree(&symbols);
assert_eq!(tree.len(), 2);
assert!(tree[0].members.is_empty());
assert!(tree[1].members.is_empty());
}
#[test]
fn methods_nest_under_class() {
let symbols = vec![
make_symbol("UserService", SymbolKind::Class, None, vec![], true),
make_symbol(
"getUser",
SymbolKind::Method,
Some("UserService"),
vec!["UserService"],
false,
),
make_symbol(
"addUser",
SymbolKind::Method,
Some("UserService"),
vec!["UserService"],
false,
),
];
let tree = build_outline_tree(&symbols);
assert_eq!(tree.len(), 1, "methods should not appear at top level");
assert_eq!(tree[0].name, "UserService");
assert_eq!(tree[0].members.len(), 2);
assert_eq!(tree[0].members[0].name, "getUser");
assert_eq!(tree[0].members[1].name, "addUser");
}
#[test]
fn methods_not_duplicated_at_top_level() {
let symbols = vec![
make_symbol("Foo", SymbolKind::Class, None, vec![], false),
make_symbol("bar", SymbolKind::Method, Some("Foo"), vec!["Foo"], false),
];
let tree = build_outline_tree(&symbols);
assert!(
tree.iter().all(|e| e.name != "bar"),
"method should not be at top level"
);
assert_eq!(tree[0].members.len(), 1);
}
#[test]
fn multi_level_nesting_python() {
let symbols = vec![
make_symbol("OuterClass", SymbolKind::Class, None, vec![], false),
make_symbol(
"InnerClass",
SymbolKind::Class,
Some("OuterClass"),
vec!["OuterClass"],
false,
),
make_symbol(
"inner_method",
SymbolKind::Method,
Some("InnerClass"),
vec!["OuterClass", "InnerClass"],
false,
),
make_symbol(
"outer_method",
SymbolKind::Method,
Some("OuterClass"),
vec!["OuterClass"],
false,
),
];
let tree = build_outline_tree(&symbols);
assert_eq!(tree.len(), 1, "only OuterClass at top level");
let outer = &tree[0];
assert_eq!(outer.name, "OuterClass");
assert_eq!(outer.members.len(), 2, "InnerClass + outer_method");
let inner = outer
.members
.iter()
.find(|m| m.name == "InnerClass")
.unwrap();
assert_eq!(inner.members.len(), 1);
assert_eq!(inner.members[0].name, "inner_method");
}
#[test]
fn all_symbol_kinds_handled() {
let symbols = vec![
make_symbol("f", SymbolKind::Function, None, vec![], false),
make_symbol("C", SymbolKind::Class, None, vec![], false),
make_symbol("m", SymbolKind::Method, Some("C"), vec!["C"], false),
make_symbol("S", SymbolKind::Struct, None, vec![], false),
make_symbol("I", SymbolKind::Interface, None, vec![], false),
make_symbol("E", SymbolKind::Enum, None, vec![], false),
make_symbol("T", SymbolKind::TypeAlias, None, vec![], false),
];
let tree = build_outline_tree(&symbols);
assert_eq!(tree.len(), 6);
let kinds: Vec<&str> = tree.iter().map(|e| e.kind.as_str()).collect();
assert!(kinds.contains(&"function"));
assert!(kinds.contains(&"class"));
assert!(kinds.contains(&"struct"));
assert!(kinds.contains(&"interface"));
assert!(kinds.contains(&"enum"));
assert!(kinds.contains(&"type_alias"));
let class_entry = tree.iter().find(|e| e.name == "C").unwrap();
assert_eq!(class_entry.members.len(), 1);
assert_eq!(class_entry.members[0].kind, "method");
}
#[test]
fn exported_flag_preserved() {
let symbols = vec![
make_symbol("exported_fn", SymbolKind::Function, None, vec![], true),
make_symbol("internal_fn", SymbolKind::Function, None, vec![], false),
];
let tree = build_outline_tree(&symbols);
let exported = tree.iter().find(|e| e.name == "exported_fn").unwrap();
let internal = tree.iter().find(|e| e.name == "internal_fn").unwrap();
assert!(exported.exported);
assert!(!internal.exported);
}
#[test]
fn orphan_child_promoted_to_top_level() {
let symbols = vec![make_symbol(
"orphan",
SymbolKind::Method,
Some("MissingParent"),
vec!["MissingParent"],
false,
)];
let tree = build_outline_tree(&symbols);
assert_eq!(tree.len(), 1, "orphan should be promoted to top level");
assert_eq!(tree[0].name, "orphan");
}
}