1mod 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#[non_exhaustive]
22#[derive(Debug, Clone, Serialize)]
23pub struct NavResult {
24 pub nodes: Vec<NodeRef>,
26 pub source: Vec<String>,
28}
29
30#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
170#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
171pub struct MutationRequest {
172 pub target: NodeRef,
174 pub operation: MutationOp,
176}
177
178#[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,
186 Insert {
188 position: InsertPosition,
190 source: String,
192 },
193 Replace {
195 source: String,
197 },
198}
199
200#[non_exhaustive]
202#[derive(Debug, Clone)]
203pub struct BatchMutationResult {
204 pub results: Vec<Result<MutationResult, AqlError>>,
206 pub files_modified: Vec<RelativePath>,
208}
209
210pub 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 let indexed: Vec<(usize, MutationRequest)> = mutations.into_iter().enumerate().collect();
229
230 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 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 let execute = || {
249 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 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
295fn apply_file_mutations(
298 project_root: &ProjectRoot,
299 file: &RelativePath,
300 mutations: &mut [(usize, MutationRequest)],
301) -> Vec<(usize, Result<MutationResult, AqlError>)> {
302 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(¤t_source, &req.target)
321 .map(|r| r.result)
322 .map_err(AqlError::from),
323 MutationOp::Insert { position, source } => {
324 mutate::insert_source(¤t_source, file, &req.target, *position, source)
325 .map_err(AqlError::from)
326 }
327 MutationOp::Replace { source } => {
328 mutate::replace_node(¤t_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 if let Err(e) = write_file(project_root, file, ¤t_source) {
346 return results
348 .into_iter()
349 .map(|(idx, _)| (idx, Err(e.clone())))
350 .collect();
351 }
352
353 results
354}
355
356fn 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
363fn 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 let root = test_root();
387 let file = RelativePath::from("src/selector.rs");
388
389 let result = select(&root, &file, None, "function_declaration").unwrap();
391
392 assert!(
396 result.nodes.is_empty() || !result.nodes.is_empty(),
397 "select should return a result regardless of matches"
398 );
399 }
400}