1use crate::error::RipsedError;
2use crate::operation::{Op, TransformMode};
3
4#[derive(Debug, Clone)]
6pub struct Script {
7 pub operations: Vec<ScriptOp>,
8}
9
10#[derive(Debug, Clone)]
12pub struct ScriptOp {
13 pub op: Op,
14 pub glob: Option<String>,
15}
16
17pub fn parse_script(input: &str) -> Result<Script, RipsedError> {
23 let mut operations = Vec::new();
24
25 for (line_idx, raw_line) in input.lines().enumerate() {
26 let line_num = line_idx + 1;
27 let trimmed = raw_line.trim();
28
29 if trimmed.is_empty() || trimmed.starts_with('#') {
31 continue;
32 }
33
34 let effective = strip_inline_comment(trimmed);
36
37 let script_op = parse_script_line(&effective, line_num)?;
38 operations.push(script_op);
39 }
40
41 Ok(Script { operations })
42}
43
44fn strip_inline_comment(line: &str) -> String {
46 let mut in_double_quote = false;
47 let mut in_single_quote = false;
48 let mut escaped = false;
49
50 for (i, ch) in line.char_indices() {
51 if escaped {
52 escaped = false;
53 continue;
54 }
55 if ch == '\\' {
56 escaped = true;
57 continue;
58 }
59 if ch == '"' && !in_single_quote {
60 in_double_quote = !in_double_quote;
61 } else if ch == '\'' && !in_double_quote {
62 in_single_quote = !in_single_quote;
63 } else if ch == '#' && !in_double_quote && !in_single_quote {
64 return line[..i].trim_end().to_string();
65 }
66 }
67
68 line.to_string()
69}
70
71fn tokenize(line: &str) -> Vec<String> {
76 let mut tokens = Vec::new();
77 let mut current = String::new();
78 let mut chars = line.chars().peekable();
79 let mut in_double_quote = false;
80 let mut in_single_quote = false;
81 let mut had_quote = false;
84
85 while let Some(ch) = chars.next() {
86 if in_double_quote {
87 if ch == '\\' {
88 if let Some(&next) = chars.peek() {
89 match next {
90 '"' | '\\' => {
91 current.push(next);
92 chars.next();
93 }
94 'n' => {
95 current.push('\n');
96 chars.next();
97 }
98 't' => {
99 current.push('\t');
100 chars.next();
101 }
102 _ => {
103 current.push('\\');
106 current.push(next);
107 chars.next();
108 }
109 }
110 } else {
111 current.push('\\');
112 }
113 } else if ch == '"' {
114 in_double_quote = false;
115 } else {
116 current.push(ch);
117 }
118 } else if in_single_quote {
119 if ch == '\'' {
120 in_single_quote = false;
121 } else {
122 current.push(ch);
123 }
124 } else if ch == '"' {
125 in_double_quote = true;
126 had_quote = true;
127 } else if ch == '\'' {
128 in_single_quote = true;
129 had_quote = true;
130 } else if ch.is_whitespace() {
131 if !current.is_empty() || had_quote {
132 tokens.push(std::mem::take(&mut current));
133 had_quote = false;
134 }
135 } else {
136 current.push(ch);
137 }
138 }
139
140 if !current.is_empty() || had_quote {
141 tokens.push(current);
142 }
143
144 tokens
145}
146
147fn parse_script_line(line: &str, line_num: usize) -> Result<ScriptOp, RipsedError> {
149 let tokens = tokenize(line);
150
151 if tokens.is_empty() {
152 return Err(script_error(line_num, "empty operation line"));
153 }
154
155 let op_name = tokens[0].to_lowercase();
156 let args = &tokens[1..];
157
158 let mut regex = false;
160 let mut case_insensitive = false;
161 let mut glob: Option<String> = None;
162 let mut positional: Vec<String> = Vec::new();
163 let mut named: std::collections::HashMap<String, String> = std::collections::HashMap::new();
164
165 let mut i = 0;
166 while i < args.len() {
167 match args[i].as_str() {
168 "-e" | "--regex" => regex = true,
169 "-i" | "--case-insensitive" => case_insensitive = true,
170 "--glob" => {
171 i += 1;
172 if i >= args.len() {
173 return Err(script_error(line_num, "--glob requires a value"));
174 }
175 glob = Some(args[i].clone());
176 }
177 "--mode" => {
178 i += 1;
179 if i >= args.len() {
180 return Err(script_error(line_num, "--mode requires a value"));
181 }
182 named.insert("mode".to_string(), args[i].clone());
183 }
184 "--prefix" => {
185 i += 1;
186 if i >= args.len() {
187 return Err(script_error(line_num, "--prefix requires a value"));
188 }
189 named.insert("prefix".to_string(), args[i].clone());
190 }
191 "--suffix" => {
192 i += 1;
193 if i >= args.len() {
194 return Err(script_error(line_num, "--suffix requires a value"));
195 }
196 named.insert("suffix".to_string(), args[i].clone());
197 }
198 "--amount" => {
199 i += 1;
200 if i >= args.len() {
201 return Err(script_error(line_num, "--amount requires a value"));
202 }
203 named.insert("amount".to_string(), args[i].clone());
204 }
205 "--use-tabs" => {
206 named.insert("use_tabs".to_string(), "true".to_string());
207 }
208 other => {
209 if other.starts_with('-') {
210 return Err(script_error(line_num, &format!("unknown flag '{other}'")));
211 }
212 positional.push(other.to_string());
213 }
214 }
215 i += 1;
216 }
217
218 let op = match op_name.as_str() {
219 "replace" => {
220 require_positional_count(&positional, 2, "replace", line_num)?;
221 Op::Replace {
222 find: positional[0].clone(),
223 replace: positional[1].clone(),
224 regex,
225 case_insensitive,
226 }
227 }
228 "delete" => {
229 require_positional_count(&positional, 1, "delete", line_num)?;
230 Op::Delete {
231 find: positional[0].clone(),
232 regex,
233 case_insensitive,
234 }
235 }
236 "insert_after" => {
237 require_positional_count(&positional, 2, "insert_after", line_num)?;
238 Op::InsertAfter {
239 find: positional[0].clone(),
240 content: positional[1].clone(),
241 regex,
242 case_insensitive,
243 }
244 }
245 "insert_before" => {
246 require_positional_count(&positional, 2, "insert_before", line_num)?;
247 Op::InsertBefore {
248 find: positional[0].clone(),
249 content: positional[1].clone(),
250 regex,
251 case_insensitive,
252 }
253 }
254 "replace_line" => {
255 require_positional_count(&positional, 2, "replace_line", line_num)?;
256 Op::ReplaceLine {
257 find: positional[0].clone(),
258 content: positional[1].clone(),
259 regex,
260 case_insensitive,
261 }
262 }
263 "transform" => {
264 require_positional_count(&positional, 1, "transform", line_num)?;
265 let mode_str = named
266 .get("mode")
267 .ok_or_else(|| script_error(line_num, "transform requires --mode <mode>"))?;
268 let mode: TransformMode = mode_str
269 .parse()
270 .map_err(|e: String| script_error(line_num, &e))?;
271 Op::Transform {
272 find: positional[0].clone(),
273 mode,
274 regex,
275 case_insensitive,
276 }
277 }
278 "surround" => {
279 require_positional_count(&positional, 1, "surround", line_num)?;
280 let prefix = named
281 .get("prefix")
282 .ok_or_else(|| script_error(line_num, "surround requires --prefix <value>"))?
283 .clone();
284 let suffix = named
285 .get("suffix")
286 .ok_or_else(|| script_error(line_num, "surround requires --suffix <value>"))?
287 .clone();
288 Op::Surround {
289 find: positional[0].clone(),
290 prefix,
291 suffix,
292 regex,
293 case_insensitive,
294 }
295 }
296 "indent" => {
297 require_positional_count(&positional, 1, "indent", line_num)?;
298 let amount = parse_amount(&named, line_num, 4)?;
299 let use_tabs = named.contains_key("use_tabs");
300 Op::Indent {
301 find: positional[0].clone(),
302 amount,
303 use_tabs,
304 regex,
305 case_insensitive,
306 }
307 }
308 "dedent" => {
309 require_positional_count(&positional, 1, "dedent", line_num)?;
310 let amount = parse_amount(&named, line_num, 4)?;
311 Op::Dedent {
312 find: positional[0].clone(),
313 amount,
314 regex,
315 case_insensitive,
316 }
317 }
318 other => {
319 return Err(script_error(
320 line_num,
321 &format!(
322 "unknown operation '{other}'. Valid operations: replace, delete, \
323 insert_after, insert_before, replace_line, transform, surround, \
324 indent, dedent"
325 ),
326 ));
327 }
328 };
329
330 Ok(ScriptOp { op, glob })
331}
332
333fn require_positional_count(
334 positional: &[String],
335 expected: usize,
336 op_name: &str,
337 line_num: usize,
338) -> Result<(), RipsedError> {
339 if positional.len() < expected {
340 return Err(script_error(
341 line_num,
342 &format!(
343 "'{op_name}' requires {expected} argument(s), got {}",
344 positional.len()
345 ),
346 ));
347 }
348 Ok(())
349}
350
351fn parse_amount(
352 named: &std::collections::HashMap<String, String>,
353 line_num: usize,
354 default: usize,
355) -> Result<usize, RipsedError> {
356 match named.get("amount") {
357 Some(s) => s
358 .parse::<usize>()
359 .map_err(|_| script_error(line_num, &format!("invalid --amount value: '{s}'"))),
360 None => Ok(default),
361 }
362}
363
364fn script_error(line_num: usize, detail: &str) -> RipsedError {
365 RipsedError::invalid_request(
366 format!("Script parse error at line {line_num}: {detail}"),
367 format!("Check the syntax at line {line_num} of your .rip script file."),
368 )
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374 use crate::operation::TransformMode;
375
376 #[test]
379 fn empty_script_produces_no_ops() {
380 let script = parse_script("").unwrap();
381 assert!(script.operations.is_empty());
382 }
383
384 #[test]
385 fn comments_and_blank_lines_are_skipped() {
386 let input = r#"
387# This is a comment
388 # Indented comment
389
390# Another comment
391"#;
392 let script = parse_script(input).unwrap();
393 assert!(script.operations.is_empty());
394 }
395
396 #[test]
397 fn inline_comments_are_stripped() {
398 let input = r#"replace "old" "new" # this is a comment"#;
399 let script = parse_script(input).unwrap();
400 assert_eq!(script.operations.len(), 1);
401 if let Op::Replace { find, replace, .. } = &script.operations[0].op {
402 assert_eq!(find, "old");
403 assert_eq!(replace, "new");
404 } else {
405 panic!("expected Replace op");
406 }
407 }
408
409 #[test]
410 fn hash_inside_quotes_is_not_a_comment() {
411 let input = r#"replace "old#value" "new#value""#;
412 let script = parse_script(input).unwrap();
413 assert_eq!(script.operations.len(), 1);
414 if let Op::Replace { find, replace, .. } = &script.operations[0].op {
415 assert_eq!(find, "old#value");
416 assert_eq!(replace, "new#value");
417 } else {
418 panic!("expected Replace op");
419 }
420 }
421
422 #[test]
425 fn double_quoted_strings_with_spaces() {
426 let input = r#"replace "hello world" "goodbye world""#;
427 let script = parse_script(input).unwrap();
428 if let Op::Replace { find, replace, .. } = &script.operations[0].op {
429 assert_eq!(find, "hello world");
430 assert_eq!(replace, "goodbye world");
431 } else {
432 panic!("expected Replace op");
433 }
434 }
435
436 #[test]
437 fn single_quoted_strings() {
438 let input = "replace 'hello world' 'goodbye world'";
439 let script = parse_script(input).unwrap();
440 if let Op::Replace { find, replace, .. } = &script.operations[0].op {
441 assert_eq!(find, "hello world");
442 assert_eq!(replace, "goodbye world");
443 } else {
444 panic!("expected Replace op");
445 }
446 }
447
448 #[test]
449 fn escaped_quotes_in_double_quotes() {
450 let input = r#"replace "say \"hello\"" "say \"goodbye\"""#;
451 let script = parse_script(input).unwrap();
452 if let Op::Replace { find, replace, .. } = &script.operations[0].op {
453 assert_eq!(find, r#"say "hello""#);
454 assert_eq!(replace, r#"say "goodbye""#);
455 } else {
456 panic!("expected Replace op");
457 }
458 }
459
460 #[test]
461 fn unquoted_strings() {
462 let input = "replace old_name new_name";
463 let script = parse_script(input).unwrap();
464 if let Op::Replace { find, replace, .. } = &script.operations[0].op {
465 assert_eq!(find, "old_name");
466 assert_eq!(replace, "new_name");
467 } else {
468 panic!("expected Replace op");
469 }
470 }
471
472 #[test]
475 fn parse_replace_basic() {
476 let input = r#"replace "old" "new""#;
477 let script = parse_script(input).unwrap();
478 assert_eq!(script.operations.len(), 1);
479 let op = &script.operations[0].op;
480 assert_eq!(
481 *op,
482 Op::Replace {
483 find: "old".to_string(),
484 replace: "new".to_string(),
485 regex: false,
486 case_insensitive: false,
487 }
488 );
489 }
490
491 #[test]
492 fn parse_replace_with_regex() {
493 let input = r#"replace -e "fn\s+old_(\w+)" "fn new_$1""#;
494 let script = parse_script(input).unwrap();
495 let op = &script.operations[0].op;
496 assert_eq!(
497 *op,
498 Op::Replace {
499 find: r"fn\s+old_(\w+)".to_string(),
500 replace: "fn new_$1".to_string(),
501 regex: true,
502 case_insensitive: false,
503 }
504 );
505 }
506
507 #[test]
508 fn parse_replace_with_case_insensitive() {
509 let input = r#"replace -i "hello" "goodbye""#;
510 let script = parse_script(input).unwrap();
511 let op = &script.operations[0].op;
512 assert!(op.is_case_insensitive());
513 }
514
515 #[test]
516 fn parse_replace_with_glob() {
517 let input = r#"replace "old" "new" --glob "*.rs""#;
518 let script = parse_script(input).unwrap();
519 assert_eq!(script.operations[0].glob, Some("*.rs".to_string()));
520 }
521
522 #[test]
525 fn parse_delete_basic() {
526 let input = r#"delete "console.log""#;
527 let script = parse_script(input).unwrap();
528 assert_eq!(
529 script.operations[0].op,
530 Op::Delete {
531 find: "console.log".to_string(),
532 regex: false,
533 case_insensitive: false,
534 }
535 );
536 }
537
538 #[test]
539 fn parse_delete_with_regex() {
540 let input = r#"delete -e "^\s*//\s*TODO:.*$""#;
541 let script = parse_script(input).unwrap();
542 let op = &script.operations[0].op;
543 assert!(op.is_regex());
544 assert_eq!(op.find_pattern(), r"^\s*//\s*TODO:.*$");
545 }
546
547 #[test]
550 fn parse_insert_after() {
551 let input = r#"insert_after "use serde;" "use serde_json;""#;
552 let script = parse_script(input).unwrap();
553 assert_eq!(
554 script.operations[0].op,
555 Op::InsertAfter {
556 find: "use serde;".to_string(),
557 content: "use serde_json;".to_string(),
558 regex: false,
559 case_insensitive: false,
560 }
561 );
562 }
563
564 #[test]
567 fn parse_insert_before() {
568 let input = r#"insert_before "fn main" "// Entry point""#;
569 let script = parse_script(input).unwrap();
570 assert_eq!(
571 script.operations[0].op,
572 Op::InsertBefore {
573 find: "fn main".to_string(),
574 content: "// Entry point".to_string(),
575 regex: false,
576 case_insensitive: false,
577 }
578 );
579 }
580
581 #[test]
584 fn parse_replace_line() {
585 let input = r#"replace_line "version = 1" "version = 2""#;
586 let script = parse_script(input).unwrap();
587 assert_eq!(
588 script.operations[0].op,
589 Op::ReplaceLine {
590 find: "version = 1".to_string(),
591 content: "version = 2".to_string(),
592 regex: false,
593 case_insensitive: false,
594 }
595 );
596 }
597
598 #[test]
601 fn parse_transform() {
602 let input = r#"transform "functionName" --mode snake_case"#;
603 let script = parse_script(input).unwrap();
604 assert_eq!(
605 script.operations[0].op,
606 Op::Transform {
607 find: "functionName".to_string(),
608 mode: TransformMode::SnakeCase,
609 regex: false,
610 case_insensitive: false,
611 }
612 );
613 }
614
615 #[test]
616 fn parse_transform_upper() {
617 let input = r#"transform "hello" --mode upper"#;
618 let script = parse_script(input).unwrap();
619 if let Op::Transform { mode, .. } = &script.operations[0].op {
620 assert_eq!(*mode, TransformMode::Upper);
621 } else {
622 panic!("expected Transform op");
623 }
624 }
625
626 #[test]
629 fn parse_surround() {
630 let input = r#"surround "word" --prefix "(" --suffix ")""#;
631 let script = parse_script(input).unwrap();
632 assert_eq!(
633 script.operations[0].op,
634 Op::Surround {
635 find: "word".to_string(),
636 prefix: "(".to_string(),
637 suffix: ")".to_string(),
638 regex: false,
639 case_insensitive: false,
640 }
641 );
642 }
643
644 #[test]
647 fn parse_indent_with_amount() {
648 let input = r#"indent "nested" --amount 4"#;
649 let script = parse_script(input).unwrap();
650 assert_eq!(
651 script.operations[0].op,
652 Op::Indent {
653 find: "nested".to_string(),
654 amount: 4,
655 use_tabs: false,
656 regex: false,
657 case_insensitive: false,
658 }
659 );
660 }
661
662 #[test]
663 fn parse_indent_default_amount() {
664 let input = r#"indent "nested""#;
665 let script = parse_script(input).unwrap();
666 if let Op::Indent { amount, .. } = &script.operations[0].op {
667 assert_eq!(*amount, 4);
668 } else {
669 panic!("expected Indent op");
670 }
671 }
672
673 #[test]
676 fn parse_dedent() {
677 let input = r#"dedent "over_indented" --amount 2"#;
678 let script = parse_script(input).unwrap();
679 assert_eq!(
680 script.operations[0].op,
681 Op::Dedent {
682 find: "over_indented".to_string(),
683 amount: 2,
684 regex: false,
685 case_insensitive: false,
686 }
687 );
688 }
689
690 #[test]
693 fn parse_multi_operation_script() {
694 let input = r#"
695# Rename old_name to new_name
696replace "old_name" "new_name"
697
698# Remove debug lines
699delete -e "^\s*console\.log"
700
701# Add import
702insert_after "use serde;" "use serde_json;"
703"#;
704 let script = parse_script(input).unwrap();
705 assert_eq!(script.operations.len(), 3);
706
707 assert!(matches!(script.operations[0].op, Op::Replace { .. }));
708 assert!(matches!(script.operations[1].op, Op::Delete { .. }));
709 assert!(matches!(script.operations[2].op, Op::InsertAfter { .. }));
710 }
711
712 #[test]
715 fn error_unknown_operation() {
716 let input = r#"frobnicate "hello" "world""#;
717 let err = parse_script(input).unwrap_err();
718 assert!(err.message.contains("unknown operation"));
719 assert!(err.message.contains("line 1"));
720 }
721
722 #[test]
723 fn error_missing_args_for_replace() {
724 let input = r#"replace "only_one_arg""#;
725 let err = parse_script(input).unwrap_err();
726 assert!(err.message.contains("requires 2 argument"));
727 assert!(err.message.contains("line 1"));
728 }
729
730 #[test]
731 fn error_missing_args_for_delete() {
732 let input = "delete";
733 let err = parse_script(input).unwrap_err();
734 assert!(err.message.contains("requires 1 argument"));
735 }
736
737 #[test]
738 fn error_transform_missing_mode() {
739 let input = r#"transform "hello""#;
740 let err = parse_script(input).unwrap_err();
741 assert!(err.message.contains("--mode"));
742 }
743
744 #[test]
745 fn error_transform_invalid_mode() {
746 let input = r#"transform "hello" --mode invalid_mode"#;
747 let err = parse_script(input).unwrap_err();
748 assert!(err.message.contains("unknown transform mode"));
749 }
750
751 #[test]
752 fn error_surround_missing_prefix() {
753 let input = r#"surround "word" --suffix ")""#;
754 let err = parse_script(input).unwrap_err();
755 assert!(err.message.contains("--prefix"));
756 }
757
758 #[test]
759 fn error_surround_missing_suffix() {
760 let input = r#"surround "word" --prefix "("")"#;
761 let err = parse_script(input).unwrap_err();
762 assert!(err.message.contains("--suffix"));
763 }
764
765 #[test]
766 fn error_unknown_flag() {
767 let input = r#"replace --unknown "hello" "world""#;
768 let err = parse_script(input).unwrap_err();
769 assert!(err.message.contains("unknown flag"));
770 }
771
772 #[test]
773 fn error_glob_missing_value() {
774 let input = r#"replace "a" "b" --glob"#;
775 let err = parse_script(input).unwrap_err();
776 assert!(err.message.contains("--glob requires a value"));
777 }
778
779 #[test]
780 fn error_invalid_amount() {
781 let input = r#"indent "hello" --amount abc"#;
782 let err = parse_script(input).unwrap_err();
783 assert!(err.message.contains("invalid --amount"));
784 }
785
786 #[test]
787 fn error_line_number_is_correct() {
788 let input = "# comment\n\nreplace \"a\" \"b\"\nbad_op \"x\"";
789 let err = parse_script(input).unwrap_err();
790 assert!(
791 err.message.contains("line 4"),
792 "Error should reference line 4, got: {}",
793 err.message
794 );
795 }
796
797 #[test]
800 fn tokenize_simple() {
801 let tokens = tokenize("replace old new");
802 assert_eq!(tokens, vec!["replace", "old", "new"]);
803 }
804
805 #[test]
806 fn tokenize_double_quoted() {
807 let tokens = tokenize(r#"replace "hello world" "goodbye world""#);
808 assert_eq!(tokens, vec!["replace", "hello world", "goodbye world"]);
809 }
810
811 #[test]
812 fn tokenize_single_quoted() {
813 let tokens = tokenize("replace 'hello world' 'goodbye world'");
814 assert_eq!(tokens, vec!["replace", "hello world", "goodbye world"]);
815 }
816
817 #[test]
818 fn tokenize_mixed_quotes() {
819 let tokens = tokenize(r#"replace 'find this' "replace that""#);
820 assert_eq!(tokens, vec!["replace", "find this", "replace that"]);
821 }
822
823 #[test]
824 fn tokenize_escaped_double_quote() {
825 let tokens = tokenize(r#"replace "say \"hi\"" new"#);
826 assert_eq!(tokens, vec!["replace", r#"say "hi""#, "new"]);
827 }
828
829 #[test]
830 fn tokenize_flags() {
831 let tokens = tokenize(r#"replace -e "pattern" "replacement" --glob "*.rs""#);
832 assert_eq!(
833 tokens,
834 vec!["replace", "-e", "pattern", "replacement", "--glob", "*.rs"]
835 );
836 }
837
838 #[test]
839 fn tokenize_empty_string() {
840 let tokens = tokenize(r#"replace "" "new""#);
841 assert_eq!(tokens, vec!["replace", "", "new"]);
842 }
843
844 #[test]
847 fn parse_replace_regex_case_insensitive_glob() {
848 let input = r#"replace -e -i "pattern" "replacement" --glob "*.txt""#;
849 let script = parse_script(input).unwrap();
850 let sop = &script.operations[0];
851 assert!(sop.op.is_regex());
852 assert!(sop.op.is_case_insensitive());
853 assert_eq!(sop.glob, Some("*.txt".to_string()));
854 }
855
856 #[test]
857 fn parse_long_form_flags() {
858 let input = r#"replace --regex --case-insensitive "a" "b""#;
859 let script = parse_script(input).unwrap();
860 let op = &script.operations[0].op;
861 assert!(op.is_regex());
862 assert!(op.is_case_insensitive());
863 }
864
865 #[test]
866 fn parse_indent_with_use_tabs() {
867 let input = r#"indent "nested" --amount 1 --use-tabs"#;
868 let script = parse_script(input).unwrap();
869 if let Op::Indent {
870 amount, use_tabs, ..
871 } = &script.operations[0].op
872 {
873 assert_eq!(*amount, 1);
874 assert!(*use_tabs);
875 } else {
876 panic!("expected Indent op");
877 }
878 }
879}