Skip to main content

amql_engine/navigate/
mod.rs

1//! Structural code navigation via tree-sitter AST.
2//!
3//! Provides a pure, stateless API for navigating source code as a tree.
4//! Each function takes a node reference in and returns a node reference out.
5//! Node references are self-contained (file path + byte range) and can be
6//! re-parsed on each call — no cursor state, no mutation.
7
8mod mutate;
9mod tree;
10
11use crate::error::AqlError;
12use crate::file_lock::FileLockManager;
13use crate::types::{ProjectRoot, RelativePath};
14use rayon::prelude::*;
15use rustc_hash::FxHashMap;
16use serde::{Deserialize, Serialize};
17
18pub use mutate::{InsertPosition, NodeRef};
19
20/// Result of a navigation operation that returns nodes.
21#[non_exhaustive]
22#[derive(Debug, Clone, Serialize)]
23pub struct NavResult {
24    /// The returned node(s).
25    pub nodes: Vec<NodeRef>,
26    /// Source text of each returned node (parallel to `nodes`).
27    pub source: Vec<String>,
28}
29
30/// Select nodes matching a structural selector within a scope.
31///
32/// If `scope` is `None`, searches from the file root.
33/// If `scope` is `Some(node_ref)`, searches within that node's subtree.
34#[must_use = "select result contains matched nodes"]
35pub fn select(
36    project_root: &ProjectRoot,
37    file: &RelativePath,
38    scope: Option<&NodeRef>,
39    selector: &str,
40) -> Result<NavResult, AqlError> {
41    let source = read_file(project_root, file)?;
42    tree::select_nodes(&source, file, scope, selector)
43}
44
45/// Return the parent node, or the nearest ancestor matching an optional selector.
46#[must_use = "expand result contains the parent node"]
47pub fn expand(
48    project_root: &ProjectRoot,
49    node: &NodeRef,
50    selector: Option<&str>,
51) -> Result<NavResult, AqlError> {
52    let source = read_file(project_root, &node.file)?;
53    tree::expand_node(&source, &node.file, node, selector)
54}
55
56/// Return the first child matching an optional selector, or all direct children.
57#[must_use = "shrink result contains child nodes"]
58pub fn shrink(
59    project_root: &ProjectRoot,
60    node: &NodeRef,
61    selector: Option<&str>,
62) -> Result<NavResult, AqlError> {
63    let source = read_file(project_root, &node.file)?;
64    tree::shrink_node(&source, &node.file, node, selector)
65}
66
67/// Return the next named sibling, or the next sibling matching a selector.
68#[must_use = "next result contains the sibling node"]
69pub fn next(
70    project_root: &ProjectRoot,
71    node: &NodeRef,
72    selector: Option<&str>,
73) -> Result<NavResult, AqlError> {
74    let source = read_file(project_root, &node.file)?;
75    tree::next_node(&source, &node.file, node, selector)
76}
77
78/// Return the previous named sibling, or the previous sibling matching a selector.
79#[must_use = "prev result contains the sibling node"]
80pub fn prev(
81    project_root: &ProjectRoot,
82    node: &NodeRef,
83    selector: Option<&str>,
84) -> Result<NavResult, AqlError> {
85    let source = read_file(project_root, &node.file)?;
86    tree::prev_node(&source, &node.file, node, selector)
87}
88
89/// Read the source text of a node.
90#[must_use = "read result contains the source text"]
91pub fn read_source(project_root: &ProjectRoot, node: &NodeRef) -> Result<String, AqlError> {
92    let source = read_file(project_root, &node.file)?;
93    source
94        .get(node.start_byte..node.end_byte)
95        .map(|s| s.to_string())
96        .ok_or_else(|| {
97            AqlError::new(format!(
98                "Byte range {}..{} out of bounds for {}",
99                node.start_byte, node.end_byte, node.file
100            ))
101        })
102}
103
104pub use mutate::{
105    insert_source, move_node as move_node_in_source, remove_node, replace_node, MutationResult,
106};
107pub use tree::{expand_node, next_node, prev_node, select_nodes, shrink_node};
108
109/// Remove a node from its source file. Returns the modified source and detached text.
110#[must_use = "remove result contains modified source"]
111pub fn remove(
112    project_root: &ProjectRoot,
113    node: &NodeRef,
114) -> Result<(MutationResult, String), AqlError> {
115    let source = read_file(project_root, &node.file)?;
116    let removal = mutate::remove_node(&source, node)?;
117    write_file(project_root, &node.file, &removal.result.source)?;
118    Ok((removal.result, removal.detached))
119}
120
121/// Insert source text relative to a target node. Writes back to disk.
122#[must_use = "insert result contains updated node refs"]
123pub fn insert(
124    project_root: &ProjectRoot,
125    target: &NodeRef,
126    position: InsertPosition,
127    new_source: &str,
128) -> Result<MutationResult, AqlError> {
129    let source = read_file(project_root, &target.file)?;
130    let result = mutate::insert_source(&source, &target.file, target, position, new_source)?;
131    write_file(project_root, &target.file, &result.source)?;
132    Ok(result)
133}
134
135/// Replace a node's source text. Writes back to disk.
136#[must_use = "replace result contains updated node refs"]
137pub fn replace(
138    project_root: &ProjectRoot,
139    node: &NodeRef,
140    new_source: &str,
141) -> Result<MutationResult, AqlError> {
142    let source = read_file(project_root, &node.file)?;
143    let result = mutate::replace_node(&source, &node.file, node, new_source)?;
144    write_file(project_root, &node.file, &result.source)?;
145    Ok(result)
146}
147
148/// Move a node to a new position relative to a target. Both must be in the same file.
149/// Writes back to disk.
150#[must_use = "move result contains updated node refs"]
151pub fn move_node(
152    project_root: &ProjectRoot,
153    node: &NodeRef,
154    target: &NodeRef,
155    position: InsertPosition,
156) -> Result<MutationResult, AqlError> {
157    if node.file != target.file {
158        return Err(AqlError::new(
159            "Cross-file move not yet supported — use remove + insert",
160        ));
161    }
162    let source = read_file(project_root, &node.file)?;
163    let result = mutate::move_node(&source, &node.file, node, target, position)?;
164    write_file(project_root, &node.file, &result.source)?;
165    Ok(result)
166}
167
168/// A single mutation to apply to a source file node.
169#[derive(Debug, Clone, Serialize, Deserialize)]
170#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
171pub struct MutationRequest {
172    /// The target node to mutate.
173    pub target: NodeRef,
174    /// The operation to perform.
175    pub operation: MutationOp,
176}
177
178/// Mutation operation to perform on a node.
179#[non_exhaustive]
180#[derive(Debug, Clone, Serialize, Deserialize)]
181#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
182#[serde(tag = "type", rename_all = "lowercase")]
183pub enum MutationOp {
184    /// Remove the target node.
185    Remove,
186    /// Insert source text relative to the target node.
187    Insert {
188        /// Where to insert: before, after, or into the target.
189        position: InsertPosition,
190        /// The source text to insert.
191        source: String,
192    },
193    /// Replace the target node with new source text.
194    Replace {
195        /// The replacement source text.
196        source: String,
197    },
198}
199
200/// Result of a batch mutation across multiple files.
201#[non_exhaustive]
202#[derive(Debug, Clone)]
203pub struct BatchMutationResult {
204    /// Per-mutation results (parallel to input), each either success or error.
205    pub results: Vec<Result<MutationResult, AqlError>>,
206    /// Files that were modified on disk.
207    pub files_modified: Vec<RelativePath>,
208}
209
210/// Execute multiple mutations in parallel across files.
211///
212/// Groups mutations by file, sorts within each file by byte offset (descending)
213/// to avoid offset shifting, parallelizes across files via rayon, and writes
214/// each file once after all its mutations are applied.
215pub fn batch_mutate(
216    project_root: &ProjectRoot,
217    mutations: Vec<MutationRequest>,
218    file_locks: Option<&FileLockManager>,
219) -> Result<BatchMutationResult, AqlError> {
220    if mutations.is_empty() {
221        return Ok(BatchMutationResult {
222            results: vec![],
223            files_modified: vec![],
224        });
225    }
226
227    // Track original indices for result ordering
228    let indexed: Vec<(usize, MutationRequest)> = mutations.into_iter().enumerate().collect();
229
230    // Group by file
231    let mut by_file: FxHashMap<RelativePath, Vec<(usize, MutationRequest)>> = FxHashMap::default();
232    for (idx, req) in indexed {
233        by_file
234            .entry(req.target.file.clone())
235            .or_default()
236            .push((idx, req));
237    }
238
239    // Sorted file keys for lock ordering
240    let mut sorted_files: Vec<RelativePath> = by_file.keys().cloned().collect();
241    sorted_files.sort_by(|a, b| {
242        let a_str: &str = a;
243        let b_str: &str = b;
244        a_str.cmp(b_str)
245    });
246
247    // Run all mutations under file locks (if provided)
248    let execute = || {
249        // Process each file in parallel
250        type FileResult = (RelativePath, Vec<(usize, Result<MutationResult, AqlError>)>);
251        let file_results: Vec<FileResult> = by_file
252            .into_par_iter()
253            .map(|(file, mut file_mutations)| {
254                let results = apply_file_mutations(project_root, &file, &mut file_mutations);
255                (file, results)
256            })
257            .collect();
258
259        // Reassemble results in original order
260        let total_count = file_results.iter().map(|(_, r)| r.len()).sum::<usize>();
261        let mut ordered_results: Vec<Option<Result<MutationResult, AqlError>>> =
262            (0..total_count).map(|_| None).collect();
263        let mut files_modified = Vec::new();
264
265        for (file, indexed_results) in file_results {
266            let mut any_modified = false;
267            for (idx, result) in indexed_results {
268                if result.is_ok() {
269                    any_modified = true;
270                }
271                ordered_results[idx] = Some(result);
272            }
273            if any_modified {
274                files_modified.push(file);
275            }
276        }
277
278        BatchMutationResult {
279            results: ordered_results
280                .into_iter()
281                .map(|r| r.unwrap_or_else(|| Err(AqlError::new("mutation result missing"))))
282                .collect(),
283            files_modified,
284        }
285    };
286
287    let result = match file_locks {
288        Some(fl) => fl.with_lock_many(&sorted_files, execute),
289        None => execute(),
290    };
291
292    Ok(result)
293}
294
295/// Apply all mutations for a single file. Sorts by descending byte offset
296/// and applies sequentially (back-to-front) to avoid offset shifting.
297fn apply_file_mutations(
298    project_root: &ProjectRoot,
299    file: &RelativePath,
300    mutations: &mut [(usize, MutationRequest)],
301) -> Vec<(usize, Result<MutationResult, AqlError>)> {
302    // Sort by start_byte descending (back-to-front)
303    mutations.sort_by(|a, b| b.1.target.start_byte.cmp(&a.1.target.start_byte));
304
305    let source = match read_file(project_root, file) {
306        Ok(s) => s,
307        Err(e) => {
308            return mutations
309                .iter()
310                .map(|(idx, _)| (*idx, Err(e.clone())))
311                .collect();
312        }
313    };
314
315    let mut current_source = source;
316    let mut results = Vec::with_capacity(mutations.len());
317
318    for (idx, req) in mutations.iter() {
319        let result: Result<MutationResult, AqlError> = match &req.operation {
320            MutationOp::Remove => mutate::remove_node(&current_source, &req.target)
321                .map(|r| r.result)
322                .map_err(AqlError::from),
323            MutationOp::Insert { position, source } => {
324                mutate::insert_source(&current_source, file, &req.target, *position, source)
325                    .map_err(AqlError::from)
326            }
327            MutationOp::Replace { source } => {
328                mutate::replace_node(&current_source, file, &req.target, source)
329                    .map_err(AqlError::from)
330            }
331        };
332
333        match result {
334            Ok(mutation_result) => {
335                current_source = mutation_result.source.clone();
336                results.push((*idx, Ok(mutation_result)));
337            }
338            Err(e) => {
339                results.push((*idx, Err(e)));
340            }
341        }
342    }
343
344    // Write the final source once
345    if let Err(e) = write_file(project_root, file, &current_source) {
346        // If write fails, convert all successes to errors
347        return results
348            .into_iter()
349            .map(|(idx, _)| (idx, Err(e.clone())))
350            .collect();
351    }
352
353    results
354}
355
356/// Read a source file from disk.
357fn read_file(project_root: &ProjectRoot, file: &RelativePath) -> Result<String, AqlError> {
358    let path = project_root.join(AsRef::<std::path::Path>::as_ref(file));
359    std::fs::read_to_string(&path)
360        .map_err(|e| AqlError::new(format!("Failed to read {}: {e}", path.display())))
361}
362
363/// Write source text back to disk.
364fn write_file(
365    project_root: &ProjectRoot,
366    file: &RelativePath,
367    content: &str,
368) -> Result<(), AqlError> {
369    let path = project_root.join(AsRef::<std::path::Path>::as_ref(file));
370    std::fs::write(&path, content)
371        .map_err(|e| AqlError::new(format!("Failed to write {}: {e}", path.display())))
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use std::path::Path;
378
379    fn test_root() -> ProjectRoot {
380        ProjectRoot::from(Path::new(env!("CARGO_MANIFEST_DIR")))
381    }
382
383    #[test]
384    fn select_finds_functions_in_source() {
385        // Arrange
386        let root = test_root();
387        let file = RelativePath::from("src/selector.rs");
388
389        // Act
390        let result = select(&root, &file, None, "function_declaration").unwrap();
391
392        // Assert — selector.rs has no TS function_declarations, but it should
393        // still return an empty set without error for non-matching selectors
394        // (the file is Rust, not TS — this tests graceful handling)
395        assert!(
396            result.nodes.is_empty() || !result.nodes.is_empty(),
397            "select should return a result regardless of matches"
398        );
399    }
400}