diff --git a/src-tauri/src/commands/ai/code_intel.rs b/src-tauri/src/commands/ai/code_intel.rs index 0a0fe15..a2206bf 100644 --- a/src-tauri/src/commands/ai/code_intel.rs +++ b/src-tauri/src/commands/ai/code_intel.rs @@ -83,11 +83,7 @@ pub fn grammar_for(ext: &str) -> Option { } } -/// 判断 ext 是否为支持语言(含 Vue 借 TS)。 -/// -/// 预留:当前 handler 直接调 read_symbol(内部已兜底 ext 判断),本函数供测试 + 未来 -/// handler 层提前判 ext 省一次 fs read(不支持直接返兜底不读文件)。测试消费,非真死代码。 -#[allow(dead_code)] +/// 判断 ext 是否为支持语言(含 Vue 借 TS)。file_outline 据此提前判 ext(不支持不读文件)。 pub fn is_supported_ext(ext: &str) -> bool { matches!(ext, "rs" | "ts" | "tsx" | "js" | "jsx" | "vue" | "go" | "java" | "py") } @@ -407,6 +403,118 @@ fn collect_calls_inner( } } +/// 文件符号概览(outline)— 提取**顶层**定义符号列表,供前端预览面板做符号树。 +/// +/// 与 read_symbol 复用同一套解析(grammar_for + DEFINITION_KINDS + Vue 切 script 段), +/// 但只返精简元数据 `{ name, kind, line, signature }`(不含全文/调用,轻量)。 +/// 遍历语义:命中 DEFINITION_KINDS 的节点记入并**不下钻其子树** —— impl 内方法、 +/// 函数/类内嵌套定义不算顶层(与 IDE 符号树顶层视图一致)。 +/// +/// **不报错底线**:无 grammar / 解析失败 / 无符号 / Vue 无 script → 空 Vec,不 Err 不 panic。 +pub fn extract_outline(source: &str, ext: &str) -> Vec { + let ext_lower = ext.to_lowercase(); + // Vue 借 TS:切 script 段,行号偏移映射回原文件(与 read_symbol 同源)。 + let (parse_source, parse_ext, line_offset): (String, &str, usize) = if ext_lower == "vue" { + match extract_vue_script(source) { + Some((script, byte_off)) => { + let off_lines = source[..byte_off].matches('\n').count(); + (script, "ts", off_lines) + } + None => return Vec::new(), // 纯 template/style 无可提取符号 + } + } else { + (source.to_string(), ext_lower.as_str(), 0) + }; + + let grammar = match grammar_for(parse_ext) { + Some(g) => g, + None => return Vec::new(), // 不支持的语言 + }; + let mut parser = Parser::new(); + if parser.set_language(&grammar).is_err() { + return Vec::new(); // grammar 加载失败(ABI 不兼容) + } + let tree = match parser.parse(&parse_source, None) { + Some(t) => t, + None => return Vec::new(), // 解析失败 + }; + + let bytes = parse_source.as_bytes(); + let mut symbols = Vec::new(); + collect_top_level_symbols(tree.root_node(), bytes, line_offset, &mut symbols); + symbols +} + +/// 收集顶层定义符号(记入 + 不下钻:命中定义节点后不再进其子树)。 +fn collect_top_level_symbols( + node: Node<'_>, + source: &[u8], + line_offset: usize, + out: &mut Vec, +) { + if DEFINITION_KINDS.contains(&node.kind()) { + let name = symbol_name(node, source); + let kind = node.kind().to_string(); + let line = node.start_position().row + 1 + line_offset; + // Python 等无 `{`/`;` 终结符的语言,extract_signature 会含函数体:outline 只取首行(头行即签名)。 + let signature = extract_signature(&node_text(node, source)) + .lines() + .next() + .unwrap_or("") + .trim() + .to_string(); + out.push(serde_json::json!({ + "name": name, + "kind": kind, + "line": line, + "signature": signature, + })); + return; // 不下钻(顶层符号语义) + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + collect_top_level_symbols(child, source, line_offset, out); + } +} + +/// 取定义节点展示名:name 字段 → type 字段(Rust impl_item)→ 首个 identifier 子节点 +/// (Go type_declaration 等 name 藏在一层子节点内)。全空返空串(前端显示签名兜底)。 +fn symbol_name(node: Node<'_>, source: &[u8]) -> String { + if let Some(n) = node.child_by_field_name("name") { + let t = node_text(n, source); + if !t.is_empty() { + return t; + } + } + if let Some(n) = node.child_by_field_name("type") { + let t = node_text(n, source); + if !t.is_empty() { + return t; + } + } + find_first_identifier(node, source).unwrap_or_default() +} + +/// DFS 找首个 identifier 类子节点(Go type_declaration → type_spec → type_identifier 隔两层)。 +fn find_first_identifier(node: Node<'_>, source: &[u8]) -> Option { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + let kind = child.kind(); + if kind == "identifier" || kind.ends_with("_identifier") { + let t = node_text(child, source); + if !t.is_empty() { + return Some(t); + } + } + } + for child in node.children(&mut cursor) { + if let Some(t) = find_first_identifier(child, source) { + return Some(t); + } + } + None +} + /// 兜底返回(不报错底线):任何失败路径统一返此结构,LLM 据 fallback/suggestion 回退 grep/read_file。 fn fallback(symbol: &str, path: &str, file_hash: &str, reason: &str, suggestion: &str) -> serde_json::Value { serde_json::json!({ @@ -731,4 +839,98 @@ fn outer() { let no_script = "\n"; assert!(!contains_definition(no_script, "vue")); } + + // ============================================================ + // extract_outline — 文件符号概览(顶层符号列表) + // ============================================================ + + #[test] + fn test_extract_outline_rust_top_level() { + let src = "use std::io;\n\npub fn read_symbol(path: &str) {\n helper(path);\n}\n\nfn helper(_x: &str) {}\n\nstruct Config {\n name: String,\n}\n\nimpl Config {\n fn new() -> Self { Config { name: String::new() } }\n}\n"; + let symbols = extract_outline(src, "rs"); + // 顶层:read_symbol / helper / Config(struct) / Config(impl);impl 内 new 不下钻 + let names: Vec<&str> = symbols.iter().map(|s| s["name"].as_str().unwrap()).collect(); + assert_eq!(names, vec!["read_symbol", "helper", "Config", "Config"]); + assert_eq!(symbols[0]["kind"], "function_item"); + assert_eq!(symbols[2]["kind"], "struct_item"); + // impl Config 的 name 来自 type 字段 + assert_eq!(symbols[3]["kind"], "impl_item"); + assert_eq!(symbols[3]["name"], "Config"); + // 行号 1-based + assert_eq!(symbols[0]["line"].as_u64().unwrap(), 3); + assert_eq!(symbols[1]["line"].as_u64().unwrap(), 7); + // signature 只到首个 {,不含函数体 + assert_eq!(symbols[0]["signature"].as_str().unwrap(), "pub fn read_symbol(path: &str)"); + assert!(!symbols[0]["signature"].as_str().unwrap().contains("helper(path)")); + } + + #[test] + fn test_extract_outline_vue_line_offset() { + let vue = "\n\n\n"; + let symbols = extract_outline(vue, "vue"); + assert_eq!(symbols.len(), 2); + assert_eq!(symbols[0]["name"], "greet"); + assert_eq!(symbols[0]["kind"], "function_declaration"); + // script 内容以 \n 开头(第 3 行