1mod types;
8#[cfg(feature = "wasm")]
9mod index;
10
11use serde::{Deserialize, Serialize};
12pub use types::{NodeKind, RelativePath};
13
14#[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 pub file: RelativePath,
27 pub start_byte: usize,
29 pub end_byte: usize,
31 pub kind: NodeKind,
33 pub line: usize,
35 pub column: usize,
37 pub end_line: usize,
39 pub end_column: usize,
41}
42
43#[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,
55 After,
57 Into,
59}
60
61#[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 pub source: String,
71 pub affected_nodes: Vec<NodeRef>,
74}
75
76#[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 pub result: MutationResult,
85 pub detached: String,
87}
88
89#[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 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#[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 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 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#[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#[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 let node_text = source[node.start_byte..node.end_byte].to_string();
201
202 if node.start_byte < target.start_byte {
206 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 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
235fn 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
257fn skip_trailing_whitespace(source: &str, from: usize) -> usize {
259 let bytes = source.as_bytes();
260 let mut pos = from;
261 while pos < bytes.len() && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
263 pos += 1;
264 }
265 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
274fn 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
283fn find_body_end(source: &str, node: &NodeRef) -> usize {
285 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 node.end_byte
296}
297
298fn 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
320fn 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 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 let removal = remove_node(SOURCE, &node).unwrap();
370 let result = removal.result;
371 let detached = removal.detached;
372
373 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 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 let result =
398 insert_source(SOURCE, &test_file(), &target, InsertPosition::After, new_fn).unwrap();
399
400 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 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 let result = insert_source(
420 SOURCE,
421 &test_file(),
422 &target,
423 InsertPosition::Before,
424 new_fn,
425 )
426 .unwrap();
427
428 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 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 let result = replace_node(SOURCE, &test_file(), &node, "const MAX_RETRIES = 5;").unwrap();
446
447 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 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 let result =
471 move_node(SOURCE, &test_file(), &greet, &fetch, InsertPosition::After).unwrap();
472
473 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 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 let result = move_node(
495 SOURCE,
496 &test_file(),
497 &max_node,
498 &greet,
499 InsertPosition::Before,
500 )
501 .unwrap();
502
503 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 let bad_ref = make_ref(0, SOURCE.len() + 100);
516
517 let result = remove_node(SOURCE, &bad_ref);
519
520 assert!(result.is_err(), "should error on out-of-bounds range");
522 }
523
524 #[test]
525 fn byte_to_line_col_correct() {
526 let src = "line1\nline2\nline3";
528
529 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