Skip to main content

amql_mutate/
lib.rs

1//! Pure source code mutation operations.
2//!
3//! All functions are pure: source text + node references in, modified source +
4//! updated node references out. No file I/O happens here — callers are
5//! responsible for reading/writing files.
6
7mod types;
8#[cfg(feature = "wasm")]
9mod index;
10
11use serde::{Deserialize, Serialize};
12pub use types::{NodeKind, RelativePath};
13
14/// A self-contained reference to a node in a source file.
15///
16/// Encodes everything needed to locate and re-parse the node:
17/// file path + byte range. Each MCP call receives and returns these.
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
20#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
21#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
22#[cfg_attr(feature = "ts", ts(export))]
23#[cfg_attr(feature = "flow", flow(export))]
24pub struct NodeRef {
25    /// Relative path to the source file.
26    pub file: RelativePath,
27    /// Byte offset of the start of this node.
28    pub start_byte: usize,
29    /// Byte offset of the end of this node.
30    pub end_byte: usize,
31    /// tree-sitter node kind (e.g. "function_declaration", "class_declaration").
32    pub kind: NodeKind,
33    /// 1-based start line number.
34    pub line: usize,
35    /// 0-based start column offset.
36    pub column: usize,
37    /// 1-based end line number.
38    pub end_line: usize,
39    /// 0-based end column offset.
40    pub end_column: usize,
41}
42
43/// Position relative to a target node for insertion.
44#[non_exhaustive]
45#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
46#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]
47#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
48#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
49#[cfg_attr(feature = "ts", ts(export))]
50#[cfg_attr(feature = "flow", flow(export))]
51#[serde(rename_all = "lowercase")]
52pub enum InsertPosition {
53    /// Before the target node.
54    Before,
55    /// After the target node.
56    After,
57    /// As the first child inside the target node's body.
58    Into,
59}
60
61/// Result of a mutation operation.
62#[non_exhaustive]
63#[derive(Debug, Clone, Serialize)]
64#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
65#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
66#[cfg_attr(feature = "ts", ts(export))]
67#[cfg_attr(feature = "flow", flow(export))]
68pub struct MutationResult {
69    /// The modified source text.
70    pub source: String,
71    /// Updated node references for nodes affected by the mutation.
72    /// After a mutation, previous node refs are stale — use these instead.
73    pub affected_nodes: Vec<NodeRef>,
74}
75
76/// Result of a node removal — modified source and the extracted node text.
77#[derive(Debug, Clone, Serialize)]
78#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
79#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
80#[cfg_attr(feature = "ts", ts(export))]
81#[cfg_attr(feature = "flow", flow(export))]
82pub struct RemoveResult {
83    /// Mutation result containing the modified source.
84    pub result: MutationResult,
85    /// The text of the removed node.
86    pub detached: String,
87}
88
89/// Remove a node from the source text, returning the modified source
90/// and the detached node's text.
91#[must_use = "remove result contains modified source and detached text"]
92pub fn remove_node(source: &str, node: &NodeRef) -> Result<RemoveResult, String> {
93    validate_range(source, node)?;
94
95    let detached = source[node.start_byte..node.end_byte].to_string();
96
97    // Remove the node and any trailing whitespace/newline
98    let end = skip_trailing_whitespace(source, node.end_byte);
99    let mut modified = String::with_capacity(source.len());
100    modified.push_str(&source[..node.start_byte]);
101    modified.push_str(&source[end..]);
102
103    Ok(RemoveResult {
104        result: MutationResult {
105            source: modified,
106            affected_nodes: vec![],
107        },
108        detached,
109    })
110}
111
112/// Insert source text relative to a target node.
113#[must_use = "insert result contains modified source and new node ref"]
114pub fn insert_source(
115    source: &str,
116    file: &RelativePath,
117    target: &NodeRef,
118    position: InsertPosition,
119    new_source: &str,
120) -> Result<MutationResult, String> {
121    validate_range(source, target)?;
122
123    let (insert_at, prefix, suffix) = match position {
124        InsertPosition::Before => {
125            let indent = detect_indent(source, target.start_byte);
126            (target.start_byte, String::new(), format!("\n{indent}"))
127        }
128        InsertPosition::After => {
129            let indent = detect_indent(source, target.start_byte);
130            (target.end_byte, format!("\n{indent}"), String::new())
131        }
132        InsertPosition::Into => {
133            // Insert as last child inside the node's body
134            // Find the closing brace/bracket
135            let body_end = find_body_end(source, target);
136            let indent = detect_indent(source, target.start_byte);
137            let child_indent = format!("{indent}    ");
138            (body_end, format!("\n{child_indent}"), String::new())
139        }
140    };
141
142    let inserted = format!("{prefix}{new_source}{suffix}");
143    let inserted_len = inserted.len();
144
145    let mut result = String::with_capacity(source.len() + inserted_len);
146    result.push_str(&source[..insert_at]);
147    result.push_str(&inserted);
148    result.push_str(&source[insert_at..]);
149
150    // Build a node ref for the inserted content
151    let new_start = insert_at + prefix.len();
152    let new_end = new_start + new_source.len();
153    let new_ref = build_node_ref_from_range(&result, file, new_start, new_end);
154
155    Ok(MutationResult {
156        source: result,
157        affected_nodes: vec![new_ref],
158    })
159}
160
161/// Replace a node's source text with new content.
162#[must_use = "replace result contains modified source and new node ref"]
163pub fn replace_node(
164    source: &str,
165    file: &RelativePath,
166    node: &NodeRef,
167    new_source: &str,
168) -> Result<MutationResult, String> {
169    validate_range(source, node)?;
170
171    let mut result =
172        String::with_capacity(source.len() - (node.end_byte - node.start_byte) + new_source.len());
173    result.push_str(&source[..node.start_byte]);
174    result.push_str(new_source);
175    result.push_str(&source[node.end_byte..]);
176
177    let new_end = node.start_byte + new_source.len();
178    let new_ref = build_node_ref_from_range(&result, file, node.start_byte, new_end);
179
180    Ok(MutationResult {
181        source: result,
182        affected_nodes: vec![new_ref],
183    })
184}
185
186/// Move a node to a new position relative to a target.
187/// Both nodes must be in the same file (same source text).
188#[must_use = "move result contains modified source"]
189pub fn move_node(
190    source: &str,
191    file: &RelativePath,
192    node: &NodeRef,
193    target: &NodeRef,
194    position: InsertPosition,
195) -> Result<MutationResult, String> {
196    validate_range(source, node)?;
197    validate_range(source, target)?;
198
199    // Extract the node text first
200    let node_text = source[node.start_byte..node.end_byte].to_string();
201
202    // Remove first, then insert. Order matters for byte offsets.
203    // If node is before target, removing shifts target left.
204    // If node is after target, removing doesn't affect target.
205    if node.start_byte < target.start_byte {
206        // Remove first (shifts target)
207        let removal = remove_node(source, node)?;
208        let removed_bytes = (skip_trailing_whitespace(source, node.end_byte)) - node.start_byte;
209        let adjusted_target = NodeRef {
210            start_byte: target.start_byte - removed_bytes,
211            end_byte: target.end_byte - removed_bytes,
212            ..target.clone()
213        };
214        insert_source(
215            &removal.result.source,
216            file,
217            &adjusted_target,
218            position,
219            &node_text,
220        )
221    } else {
222        // Insert first (doesn't shift node)
223        let insert_result = insert_source(source, file, target, position, &node_text)?;
224        let inserted_bytes = insert_result.source.len() - source.len();
225        let adjusted_node = NodeRef {
226            start_byte: node.start_byte + inserted_bytes,
227            end_byte: node.end_byte + inserted_bytes,
228            ..node.clone()
229        };
230        let removal = remove_node(&insert_result.source, &adjusted_node)?;
231        Ok(removal.result)
232    }
233}
234
235// ---------------------------------------------------------------------------
236// Helpers
237// ---------------------------------------------------------------------------
238
239fn validate_range(source: &str, node: &NodeRef) -> Result<(), String> {
240    if node.end_byte > source.len() {
241        return Err(format!(
242            "Byte range {}..{} out of bounds for source (len={})",
243            node.start_byte,
244            node.end_byte,
245            source.len()
246        ));
247    }
248    if node.start_byte > node.end_byte {
249        return Err(format!(
250            "Invalid byte range: start {} > end {}",
251            node.start_byte, node.end_byte
252        ));
253    }
254    Ok(())
255}
256
257/// Skip trailing whitespace and a single newline after a removed node.
258fn skip_trailing_whitespace(source: &str, from: usize) -> usize {
259    let bytes = source.as_bytes();
260    let mut pos = from;
261    // Skip spaces/tabs
262    while pos < bytes.len() && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
263        pos += 1;
264    }
265    // Skip one newline
266    if pos < bytes.len() && bytes[pos] == b'\n' {
267        pos += 1;
268    } else if pos + 1 < bytes.len() && bytes[pos] == b'\r' && bytes[pos + 1] == b'\n' {
269        pos += 2;
270    }
271    pos
272}
273
274/// Detect the indentation of the line containing `byte_offset`.
275fn detect_indent(source: &str, byte_offset: usize) -> String {
276    let before = &source[..byte_offset];
277    let line_start = before.rfind('\n').map(|i| i + 1).unwrap_or(0);
278    let line = &source[line_start..byte_offset];
279    let indent_len = line.len() - line.trim_start().len();
280    line[..indent_len].to_string()
281}
282
283/// Find the byte offset just before the closing brace of a node's body.
284fn find_body_end(source: &str, node: &NodeRef) -> usize {
285    // Walk backwards from node.end_byte to find closing brace/bracket
286    let bytes = source.as_bytes();
287    let mut pos = node.end_byte;
288    while pos > node.start_byte {
289        pos -= 1;
290        if bytes[pos] == b'}' || bytes[pos] == b']' || bytes[pos] == b')' {
291            return pos;
292        }
293    }
294    // Fallback: insert before end
295    node.end_byte
296}
297
298/// Build a NodeRef from a byte range in source text.
299/// Computes line/column from the byte offset.
300fn build_node_ref_from_range(
301    source: &str,
302    file: &RelativePath,
303    start: usize,
304    end: usize,
305) -> NodeRef {
306    let (line, column) = byte_to_line_col(source, start);
307    let (end_line, end_column) = byte_to_line_col(source, end);
308    NodeRef {
309        file: file.clone(),
310        start_byte: start,
311        end_byte: end,
312        kind: NodeKind::from("inserted"),
313        line,
314        column,
315        end_line,
316        end_column,
317    }
318}
319
320/// Convert a byte offset to 1-based line and 0-based column.
321fn byte_to_line_col(source: &str, byte_offset: usize) -> (usize, usize) {
322    let before = &source[..byte_offset.min(source.len())];
323    let line = before.matches('\n').count() + 1;
324    let col = before.len() - before.rfind('\n').map(|i| i + 1).unwrap_or(0);
325    (line, col)
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    fn test_file() -> RelativePath {
333        RelativePath::from("test.ts")
334    }
335
336    const SOURCE: &str = r#"function greet(name: string): string {
337    return `Hello, ${name}!`;
338}
339
340async function fetchUser(id: number): Promise<User> {
341    const response = await fetch(`/api/users/${id}`);
342    return response.json();
343}
344
345const MAX_RETRIES = 3;
346"#;
347
348    fn make_ref(start: usize, end: usize) -> NodeRef {
349        NodeRef {
350            file: test_file(),
351            start_byte: start,
352            end_byte: end,
353            kind: NodeKind::from("test"),
354            line: 1,
355            column: 0,
356            end_line: 1,
357            end_column: 0,
358        }
359    }
360
361    #[test]
362    fn remove_extracts_node() {
363        // Arrange
364        let greet_start = SOURCE.find("function greet").unwrap();
365        let greet_end = SOURCE.find("}\n\nasync").unwrap() + 1;
366        let node = make_ref(greet_start, greet_end);
367
368        // Act
369        let removal = remove_node(SOURCE, &node).unwrap();
370        let result = removal.result;
371        let detached = removal.detached;
372
373        // Assert
374        assert!(
375            detached.contains("function greet"),
376            "detached should contain the function"
377        );
378        assert!(
379            !result.source.contains("function greet"),
380            "result should not contain the removed function"
381        );
382        assert!(
383            result.source.contains("async function fetchUser"),
384            "result should still contain fetchUser"
385        );
386    }
387
388    #[test]
389    fn insert_after_adds_text() {
390        // Arrange
391        let greet_start = SOURCE.find("function greet").unwrap();
392        let greet_end = SOURCE.find("}\n\nasync").unwrap() + 1;
393        let target = make_ref(greet_start, greet_end);
394        let new_fn = "function goodbye(): void {\n    console.log('bye');\n}";
395
396        // Act
397        let result =
398            insert_source(SOURCE, &test_file(), &target, InsertPosition::After, new_fn).unwrap();
399
400        // Assert
401        assert!(
402            result.source.contains(new_fn),
403            "result should contain the new function"
404        );
405        let greet_pos = result.source.find("function greet").unwrap();
406        let goodbye_pos = result.source.find("function goodbye").unwrap();
407        assert!(goodbye_pos > greet_pos, "goodbye should come after greet");
408    }
409
410    #[test]
411    fn insert_before_adds_text() {
412        // Arrange
413        let fetch_start = SOURCE.find("async function fetchUser").unwrap();
414        let fetch_end = SOURCE.find("}\n\nconst").unwrap() + 1;
415        let target = make_ref(fetch_start, fetch_end);
416        let new_fn = "function middleware(): void {}";
417
418        // Act
419        let result = insert_source(
420            SOURCE,
421            &test_file(),
422            &target,
423            InsertPosition::Before,
424            new_fn,
425        )
426        .unwrap();
427
428        // Assert
429        let middleware_pos = result.source.find("function middleware").unwrap();
430        let fetch_pos = result.source.find("async function fetchUser").unwrap();
431        assert!(
432            middleware_pos < fetch_pos,
433            "middleware should come before fetchUser"
434        );
435    }
436
437    #[test]
438    fn replace_swaps_content() {
439        // Arrange
440        let max_start = SOURCE.find("const MAX_RETRIES = 3;").unwrap();
441        let max_end = max_start + "const MAX_RETRIES = 3;".len();
442        let node = make_ref(max_start, max_end);
443
444        // Act
445        let result = replace_node(SOURCE, &test_file(), &node, "const MAX_RETRIES = 5;").unwrap();
446
447        // Assert
448        assert!(
449            result.source.contains("const MAX_RETRIES = 5;"),
450            "should contain new value"
451        );
452        assert!(
453            !result.source.contains("const MAX_RETRIES = 3;"),
454            "should not contain old value"
455        );
456    }
457
458    #[test]
459    fn move_node_forward() {
460        // Arrange — move greet after fetchUser
461        let greet_start = SOURCE.find("function greet").unwrap();
462        let greet_end = SOURCE.find("}\n\nasync").unwrap() + 1;
463        let greet = make_ref(greet_start, greet_end);
464
465        let fetch_start = SOURCE.find("async function fetchUser").unwrap();
466        let fetch_end = SOURCE.find("}\n\nconst").unwrap() + 1;
467        let fetch = make_ref(fetch_start, fetch_end);
468
469        // Act
470        let result =
471            move_node(SOURCE, &test_file(), &greet, &fetch, InsertPosition::After).unwrap();
472
473        // Assert
474        let fetch_pos = result.source.find("async function fetchUser").unwrap();
475        let greet_pos = result.source.find("function greet").unwrap();
476        assert!(
477            greet_pos > fetch_pos,
478            "greet should now come after fetchUser"
479        );
480    }
481
482    #[test]
483    fn move_node_backward() {
484        // Arrange — move MAX_RETRIES before greet
485        let max_start = SOURCE.find("const MAX_RETRIES = 3;").unwrap();
486        let max_end = max_start + "const MAX_RETRIES = 3;".len();
487        let max_node = make_ref(max_start, max_end);
488
489        let greet_start = SOURCE.find("function greet").unwrap();
490        let greet_end = SOURCE.find("}\n\nasync").unwrap() + 1;
491        let greet = make_ref(greet_start, greet_end);
492
493        // Act
494        let result = move_node(
495            SOURCE,
496            &test_file(),
497            &max_node,
498            &greet,
499            InsertPosition::Before,
500        )
501        .unwrap();
502
503        // Assert
504        let max_pos = result.source.find("const MAX_RETRIES = 3;").unwrap();
505        let greet_pos = result.source.find("function greet").unwrap();
506        assert!(
507            max_pos < greet_pos,
508            "MAX_RETRIES should now come before greet"
509        );
510    }
511
512    #[test]
513    fn out_of_bounds_returns_error() {
514        // Arrange
515        let bad_ref = make_ref(0, SOURCE.len() + 100);
516
517        // Act
518        let result = remove_node(SOURCE, &bad_ref);
519
520        // Assert
521        assert!(result.is_err(), "should error on out-of-bounds range");
522    }
523
524    #[test]
525    fn byte_to_line_col_correct() {
526        // Arrange
527        let src = "line1\nline2\nline3";
528
529        // Act and Assert
530        assert_eq!(byte_to_line_col(src, 0), (1, 0), "start of file");
531        assert_eq!(byte_to_line_col(src, 6), (2, 0), "start of line 2");
532        assert_eq!(byte_to_line_col(src, 8), (2, 2), "col 2 of line 2");
533    }
534}
535