Skip to main content

lean_ctx/core/
chunks_ts.rs

1//! Tree-sitter AST-aware code chunking for semantic search.
2//!
3//! Replaces heuristic line-prefix matching with proper AST parsing.
4//! Extracts function bodies, struct definitions, class declarations etc.
5//! as complete, self-contained chunks with accurate boundaries.
6//!
7//! Falls back to heuristic chunking for unsupported languages.
8
9#[cfg(feature = "tree-sitter")]
10use tree_sitter::{Node, Parser, Query, QueryCursor, StreamingIterator};
11
12use super::bm25_index::{ChunkKind, CodeChunk};
13
14#[cfg(feature = "tree-sitter")]
15const CHUNK_QUERY_RUST: &str = r"
16(function_item name: (identifier) @name) @chunk
17(struct_item name: (type_identifier) @name) @chunk
18(enum_item name: (type_identifier) @name) @chunk
19(trait_item name: (type_identifier) @name) @chunk
20(impl_item type: (type_identifier) @name) @chunk
21(const_item name: (identifier) @name) @chunk
22";
23
24#[cfg(feature = "tree-sitter")]
25const CHUNK_QUERY_TYPESCRIPT: &str = r"
26(function_declaration name: (identifier) @name) @chunk
27(class_declaration name: (type_identifier) @name) @chunk
28(abstract_class_declaration name: (type_identifier) @name) @chunk
29(interface_declaration name: (type_identifier) @name) @chunk
30(type_alias_declaration name: (type_identifier) @name) @chunk
31(method_definition name: (property_identifier) @name) @chunk
32(variable_declarator name: (identifier) @name value: (arrow_function)) @chunk
33";
34
35#[cfg(feature = "tree-sitter")]
36const CHUNK_QUERY_JAVASCRIPT: &str = r"
37(function_declaration name: (identifier) @name) @chunk
38(class_declaration name: (identifier) @name) @chunk
39(method_definition name: (property_identifier) @name) @chunk
40(variable_declarator name: (identifier) @name value: (arrow_function)) @chunk
41";
42
43#[cfg(feature = "tree-sitter")]
44const CHUNK_QUERY_PYTHON: &str = r"
45(function_definition name: (identifier) @name) @chunk
46(class_definition name: (identifier) @name) @chunk
47";
48
49#[cfg(feature = "tree-sitter")]
50const CHUNK_QUERY_GO: &str = r"
51(function_declaration name: (identifier) @name) @chunk
52(method_declaration name: (field_identifier) @name) @chunk
53(type_spec name: (type_identifier) @name) @chunk
54";
55
56#[cfg(feature = "tree-sitter")]
57const CHUNK_QUERY_JAVA: &str = r"
58(method_declaration name: (identifier) @name) @chunk
59(class_declaration name: (identifier) @name) @chunk
60(interface_declaration name: (identifier) @name) @chunk
61(enum_declaration name: (identifier) @name) @chunk
62(constructor_declaration name: (identifier) @name) @chunk
63";
64
65#[cfg(feature = "tree-sitter")]
66const CHUNK_QUERY_C: &str = r"
67(function_definition
68  declarator: (function_declarator
69    declarator: (identifier) @name)) @chunk
70(struct_specifier name: (type_identifier) @name) @chunk
71(enum_specifier name: (type_identifier) @name) @chunk
72";
73
74#[cfg(feature = "tree-sitter")]
75const CHUNK_QUERY_CPP: &str = r"
76(function_definition
77  declarator: (function_declarator
78    declarator: (_) @name)) @chunk
79(struct_specifier name: (type_identifier) @name) @chunk
80(class_specifier name: (type_identifier) @name) @chunk
81(enum_specifier name: (type_identifier) @name) @chunk
82(namespace_definition name: (identifier) @name) @chunk
83";
84
85/// Extract code chunks from a file using tree-sitter AST parsing.
86///
87/// Returns `None` if the language is unsupported, allowing callers to fall back
88/// to heuristic-based chunking.
89#[cfg(feature = "tree-sitter")]
90fn get_cached_query(file_ext: &str) -> Option<&'static Query> {
91    use std::collections::HashMap;
92    use std::sync::OnceLock;
93
94    static QUERY_CACHE: OnceLock<HashMap<&'static str, Query>> = OnceLock::new();
95
96    let cache = QUERY_CACHE.get_or_init(|| {
97        let mut map = HashMap::new();
98        let exts: &[&str] = &[
99            "rs", "ts", "tsx", "js", "jsx", "py", "go", "java", "c", "h", "cpp", "cc", "cxx", "hpp",
100        ];
101        for &ext in exts {
102            if let (Some(lang), Some(src)) = (get_language(ext), get_chunk_query(ext))
103                && let Ok(q) = Query::new(&lang, src)
104            {
105                map.insert(ext, q);
106            }
107        }
108        map
109    });
110
111    cache.get(file_ext)
112}
113
114/// Visit each structural chunk root (`@chunk` capture) once per tree-sitter query match.
115///
116/// `start_line` / `end_line` are **1-based** inclusive line numbers (matching [`CodeChunk`]).
117/// Returns `None` if the extension is unsupported or parsing fails.
118#[cfg(feature = "tree-sitter")]
119pub(crate) fn for_each_chunk_node(
120    content: &str,
121    file_ext: &str,
122    mut visitor: impl FnMut(Node, &str, ChunkKind, usize, usize),
123) -> Option<()> {
124    for_each_chunk_node_while(
125        content,
126        file_ext,
127        || true,
128        |node, name, kind, start, end| {
129            visitor(node, name, kind, start, end);
130            true
131        },
132    )
133}
134
135/// Stop capture traversal as soon as the consumer reaches its work budget.
136#[cfg(feature = "tree-sitter")]
137pub(crate) fn for_each_chunk_node_while(
138    content: &str,
139    file_ext: &str,
140    mut continue_scan: impl FnMut() -> bool,
141    mut visitor: impl FnMut(Node, &str, ChunkKind, usize, usize) -> bool,
142) -> Option<()> {
143    let language = get_language(file_ext)?;
144
145    thread_local! {
146        static PARSER: std::cell::RefCell<Parser> = std::cell::RefCell::new(Parser::new());
147    }
148
149    let tree = PARSER.with(|p| {
150        let mut parser = p.borrow_mut();
151        let _ = parser.set_language(&language);
152        parser.parse(content, None)
153    })?;
154
155    let query = get_cached_query(file_ext)?;
156    let chunk_idx = find_capture_index(query, "chunk")?;
157    let name_idx = find_capture_index(query, "name")?;
158
159    let source = content.as_bytes();
160    let mut cursor = QueryCursor::new();
161    let mut matches = cursor.matches(query, tree.root_node(), source);
162    let mut seen_ranges = Vec::new();
163
164    while let Some(m) = matches.next() {
165        // Budget admission precedes even containment/deduplication work.
166        if !continue_scan() {
167            break;
168        }
169        let mut chunk_node: Option<Node> = None;
170        let mut name_text = String::new();
171
172        for cap in m.captures {
173            if cap.index == chunk_idx {
174                chunk_node = Some(cap.node);
175            } else if cap.index == name_idx
176                && let Ok(text) = cap.node.utf8_text(source)
177            {
178                name_text = text.to_string();
179            }
180        }
181
182        if let Some(node) = chunk_node {
183            if name_text.is_empty() {
184                continue;
185            }
186
187            let start_row0 = node.start_position().row;
188            let end_row0 = node.end_position().row;
189
190            let range = (start_row0, end_row0);
191            if seen_ranges
192                .iter()
193                .any(|&(s, e)| s <= start_row0 && end_row0 <= e && range != (s, e))
194            {
195                continue;
196            }
197            seen_ranges.push(range);
198
199            let kind = node_kind_to_chunk_kind(node.kind());
200            if !visitor(node, name_text.as_str(), kind, start_row0 + 1, end_row0 + 1) {
201                break;
202            }
203        }
204    }
205
206    Some(())
207}
208
209#[cfg(feature = "tree-sitter")]
210pub fn extract_chunks_ts(file_path: &str, content: &str, file_ext: &str) -> Option<Vec<CodeChunk>> {
211    let mut chunks = Vec::new();
212
213    for_each_chunk_node(
214        content,
215        file_ext,
216        |node, name_text, kind, start_line, end_line| {
217            let block = match node.utf8_text(content.as_bytes()) {
218                Ok(text) => text.to_owned(),
219                Err(_) => return,
220            };
221            let token_count = super::bm25_index::tokenize_for_index(&block).len();
222
223            chunks.push(CodeChunk {
224                input_origin: None,
225                file_path: file_path.to_string(),
226                symbol_name: name_text.to_string(),
227                kind,
228                start_line,
229                end_line,
230                content: block,
231                tokens: Vec::new(),
232                token_count,
233            });
234        },
235    )?;
236
237    if chunks.is_empty() {
238        return None;
239    }
240
241    chunks.sort_by_key(|c| c.start_line);
242    Some(chunks)
243}
244
245#[cfg(not(feature = "tree-sitter"))]
246pub fn extract_chunks_ts(
247    _file_path: &str,
248    _content: &str,
249    _file_ext: &str,
250) -> Option<Vec<CodeChunk>> {
251    None
252}
253
254#[cfg(feature = "tree-sitter")]
255use lean_ctx_syntax::chunk_grammar::chunk_language as get_language;
256
257#[cfg(feature = "tree-sitter")]
258fn get_chunk_query(ext: &str) -> Option<&'static str> {
259    Some(match ext {
260        "rs" => CHUNK_QUERY_RUST,
261        "ts" | "tsx" => CHUNK_QUERY_TYPESCRIPT,
262        "js" | "jsx" => CHUNK_QUERY_JAVASCRIPT,
263        "py" => CHUNK_QUERY_PYTHON,
264        "go" => CHUNK_QUERY_GO,
265        "java" => CHUNK_QUERY_JAVA,
266        "c" | "h" => CHUNK_QUERY_C,
267        "cpp" | "cc" | "cxx" | "hpp" | "hxx" | "hh" => CHUNK_QUERY_CPP,
268        _ => return None,
269    })
270}
271
272#[cfg(feature = "tree-sitter")]
273fn find_capture_index(query: &Query, name: &str) -> Option<u32> {
274    query
275        .capture_names()
276        .iter()
277        .position(|n| *n == name)
278        .map(|i| i as u32)
279}
280
281fn node_kind_to_chunk_kind(kind: &str) -> ChunkKind {
282    match kind {
283        "function_item"
284        | "function_declaration"
285        | "function_definition"
286        | "method_declaration"
287        | "method_definition"
288        | "constructor_declaration"
289        | "variable_declarator" => ChunkKind::Function,
290
291        "struct_item"
292        | "struct_specifier"
293        | "struct_declaration"
294        | "enum_item"
295        | "enum_specifier"
296        | "enum_declaration"
297        | "trait_item"
298        | "interface_declaration"
299        | "type_alias_declaration"
300        | "type_spec" => ChunkKind::Struct,
301
302        "impl_item" => ChunkKind::Impl,
303
304        "class_declaration"
305        | "abstract_class_declaration"
306        | "class_specifier"
307        | "class_definition" => ChunkKind::Class,
308
309        "namespace_definition" | "namespace_declaration" => ChunkKind::Module,
310
311        _ => ChunkKind::Other,
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    #[test]
320    fn extract_rust_chunks() {
321        let src = r#"use std::io;
322
323pub fn process(input: &str) -> String {
324    input.to_uppercase()
325}
326
327pub struct Config {
328    pub name: String,
329    pub port: u16,
330}
331
332impl Config {
333    pub fn new() -> Self {
334        Self { name: "default".into(), port: 8080 }
335    }
336}
337
338fn helper() -> bool {
339    true
340}
341"#;
342        let chunks = extract_chunks_ts("main.rs", src, "rs").unwrap();
343        assert!(
344            chunks.len() >= 4,
345            "expected >=4 chunks, got {}",
346            chunks.len()
347        );
348
349        let names: Vec<&str> = chunks.iter().map(|c| c.symbol_name.as_str()).collect();
350        assert!(names.contains(&"process"), "got {names:?}");
351        assert!(names.contains(&"Config"), "got {names:?}");
352        assert!(names.contains(&"helper"), "got {names:?}");
353
354        let process = chunks.iter().find(|c| c.symbol_name == "process").unwrap();
355        assert!(matches!(process.kind, ChunkKind::Function));
356        assert!(process.content.contains("to_uppercase"));
357    }
358
359    #[test]
360    fn extract_typescript_chunks() {
361        let src = r"
362export function greet(name: string): string {
363    return `Hello ${name}`;
364}
365
366export class UserService {
367    findUser(id: number): User {
368        return db.find(id);
369    }
370}
371
372const handler = async (req: Request): Promise<Response> => {
373    return new Response();
374};
375";
376        let chunks = extract_chunks_ts("app.ts", src, "ts").unwrap();
377        assert!(
378            chunks.len() >= 3,
379            "expected >=3 chunks, got {}",
380            chunks.len()
381        );
382
383        let names: Vec<&str> = chunks.iter().map(|c| c.symbol_name.as_str()).collect();
384        assert!(names.contains(&"greet"), "got {names:?}");
385        assert!(names.contains(&"UserService"), "got {names:?}");
386    }
387
388    #[test]
389    fn extract_python_chunks() {
390        let src = r"
391class AuthService:
392    def __init__(self, db):
393        self.db = db
394
395    def authenticate(self, email: str) -> bool:
396        user = self.db.find(email)
397        return user is not None
398
399def create_app():
400    return Flask(__name__)
401";
402        let chunks = extract_chunks_ts("app.py", src, "py").unwrap();
403        assert!(
404            chunks.len() >= 2,
405            "expected >=2 chunks, got {}",
406            chunks.len()
407        );
408
409        let names: Vec<&str> = chunks.iter().map(|c| c.symbol_name.as_str()).collect();
410        assert!(names.contains(&"AuthService"), "got {names:?}");
411        assert!(names.contains(&"create_app"), "got {names:?}");
412
413        let auth = chunks
414            .iter()
415            .find(|c| c.symbol_name == "AuthService")
416            .unwrap();
417        assert!(auth.content.contains("authenticate"));
418    }
419
420    #[test]
421    fn chunks_contain_full_body() {
422        let src = r#"
423pub fn complex(x: i32, y: i32) -> Result<String, Error> {
424    let sum = x + y;
425    let result = format!("Sum: {}", sum);
426    if sum > 100 {
427        return Err(Error::new("too large"));
428    }
429    Ok(result)
430}
431"#;
432        let chunks = extract_chunks_ts("math.rs", src, "rs").unwrap();
433        let complex = chunks.iter().find(|c| c.symbol_name == "complex").unwrap();
434        assert!(complex.content.contains("sum > 100"));
435        assert!(complex.content.contains("Ok(result)"));
436    }
437
438    #[test]
439    fn unsupported_language_returns_none() {
440        assert!(extract_chunks_ts("file.xyz", "content", "xyz").is_none());
441    }
442
443    #[test]
444    fn empty_file_returns_none() {
445        assert!(extract_chunks_ts("empty.rs", "", "rs").is_none());
446    }
447
448    #[test]
449    fn javascript_same_line_functions_use_exact_node_content() {
450        let src = "function alpha(){return 1;}function beta(){return 2;}";
451        let chunks = extract_chunks_ts("min.js", src, "js").unwrap();
452
453        let alpha = chunks.iter().find(|c| c.symbol_name == "alpha").unwrap();
454        let beta = chunks.iter().find(|c| c.symbol_name == "beta").unwrap();
455
456        assert_eq!(alpha.content, "function alpha(){return 1;}");
457        assert_eq!(beta.content, "function beta(){return 2;}");
458    }
459
460    #[test]
461    fn chunks_sorted_by_line() {
462        let src = r"
463fn b_func() {}
464fn a_func() {}
465";
466        let chunks = extract_chunks_ts("sort.rs", src, "rs").unwrap();
467        assert!(chunks[0].start_line <= chunks[1].start_line);
468    }
469}