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 let use_tabs = named.contains_key("use_tabs");
312 Op::Dedent {
313 find: positional[0].clone(),
314 amount,
315 use_tabs,
316 regex,
317 case_insensitive,
318 }
319 }
320 other => {
321 return Err(script_error(
322 line_num,
323 &format!(
324 "unknown operation '{other}'. Valid operations: replace, delete, \
325 insert_after, insert_before, replace_line, transform, surround, \
326 indent, dedent"
327 ),
328 ));
329 }
330 };
331
332 Ok(ScriptOp { op, glob })
333}
334
335fn require_positional_count(
336 positional: &[String],
337 expected: usize,
338 op_name: &str,
339 line_num: usize,
340) -> Result<(), RipsedError> {
341 if positional.len() < expected {
342 return Err(script_error(
343 line_num,
344 &format!(
345 "'{op_name}' requires {expected} argument(s), got {}",
346 positional.len()
347 ),
348 ));
349 }
350 Ok(())
351}
352
353fn parse_amount(
354 named: &std::collections::HashMap<String, String>,
355 line_num: usize,
356 default: usize,
357) -> Result<usize, RipsedError> {
358 match named.get("amount") {
359 Some(s) => s
360 .parse::<usize>()
361 .map_err(|_| script_error(line_num, &format!("invalid --amount value: '{s}'"))),
362 None => Ok(default),
363 }
364}
365
366fn script_error(line_num: usize, detail: &str) -> RipsedError {
367 RipsedError::invalid_request(
368 format!("Script parse error at line {line_num}: {detail}"),
369 format!("Check the syntax at line {line_num} of your .rip script file."),
370 )
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376 use crate::operation::TransformMode;
377
378 #[test]
381 fn empty_script_produces_no_ops() {
382 let script = parse_script("").unwrap();
383 assert!(script.operations.is_empty());
384 }
385
386 #[test]
387 fn comments_and_blank_lines_are_skipped() {
388 let input = r#"
389# This is a comment
390 # Indented comment
391
392# Another comment
393"#;
394 let script = parse_script(input).unwrap();
395 assert!(script.operations.is_empty());
396 }
397
398 #[test]
399 fn inline_comments_are_stripped() {
400 let input = r#"replace "old" "new" # this is a comment"#;
401 let script = parse_script(input).unwrap();
402 assert_eq!(script.operations.len(), 1);
403 if let Op::Replace { find, replace, .. } = &script.operations[0].op {
404 assert_eq!(find, "old");
405 assert_eq!(replace, "new");
406 } else {
407 panic!("expected Replace op");
408 }
409 }
410
411 #[test]
412 fn hash_inside_quotes_is_not_a_comment() {
413 let input = r#"replace "old#value" "new#value""#;
414 let script = parse_script(input).unwrap();
415 assert_eq!(script.operations.len(), 1);
416 if let Op::Replace { find, replace, .. } = &script.operations[0].op {
417 assert_eq!(find, "old#value");
418 assert_eq!(replace, "new#value");
419 } else {
420 panic!("expected Replace op");
421 }
422 }
423
424 #[test]
427 fn double_quoted_strings_with_spaces() {
428 let input = r#"replace "hello world" "goodbye world""#;
429 let script = parse_script(input).unwrap();
430 if let Op::Replace { find, replace, .. } = &script.operations[0].op {
431 assert_eq!(find, "hello world");
432 assert_eq!(replace, "goodbye world");
433 } else {
434 panic!("expected Replace op");
435 }
436 }
437
438 #[test]
439 fn single_quoted_strings() {
440 let input = "replace 'hello world' 'goodbye world'";
441 let script = parse_script(input).unwrap();
442 if let Op::Replace { find, replace, .. } = &script.operations[0].op {
443 assert_eq!(find, "hello world");
444 assert_eq!(replace, "goodbye world");
445 } else {
446 panic!("expected Replace op");
447 }
448 }
449
450 #[test]
451 fn escaped_quotes_in_double_quotes() {
452 let input = r#"replace "say \"hello\"" "say \"goodbye\"""#;
453 let script = parse_script(input).unwrap();
454 if let Op::Replace { find, replace, .. } = &script.operations[0].op {
455 assert_eq!(find, r#"say "hello""#);
456 assert_eq!(replace, r#"say "goodbye""#);
457 } else {
458 panic!("expected Replace op");
459 }
460 }
461
462 #[test]
463 fn unquoted_strings() {
464 let input = "replace old_name new_name";
465 let script = parse_script(input).unwrap();
466 if let Op::Replace { find, replace, .. } = &script.operations[0].op {
467 assert_eq!(find, "old_name");
468 assert_eq!(replace, "new_name");
469 } else {
470 panic!("expected Replace op");
471 }
472 }
473
474 #[test]
477 fn parse_replace_basic() {
478 let input = r#"replace "old" "new""#;
479 let script = parse_script(input).unwrap();
480 assert_eq!(script.operations.len(), 1);
481 let op = &script.operations[0].op;
482 assert_eq!(
483 *op,
484 Op::Replace {
485 find: "old".to_string(),
486 replace: "new".to_string(),
487 regex: false,
488 case_insensitive: false,
489 }
490 );
491 }
492
493 #[test]
494 fn parse_replace_with_regex() {
495 let input = r#"replace -e "fn\s+old_(\w+)" "fn new_$1""#;
496 let script = parse_script(input).unwrap();
497 let op = &script.operations[0].op;
498 assert_eq!(
499 *op,
500 Op::Replace {
501 find: r"fn\s+old_(\w+)".to_string(),
502 replace: "fn new_$1".to_string(),
503 regex: true,
504 case_insensitive: false,
505 }
506 );
507 }
508
509 #[test]
510 fn parse_replace_with_case_insensitive() {
511 let input = r#"replace -i "hello" "goodbye""#;
512 let script = parse_script(input).unwrap();
513 let op = &script.operations[0].op;
514 assert!(op.is_case_insensitive());
515 }
516
517 #[test]
518 fn parse_replace_with_glob() {
519 let input = r#"replace "old" "new" --glob "*.rs""#;
520 let script = parse_script(input).unwrap();
521 assert_eq!(script.operations[0].glob, Some("*.rs".to_string()));
522 }
523
524 #[test]
527 fn parse_delete_basic() {
528 let input = r#"delete "console.log""#;
529 let script = parse_script(input).unwrap();
530 assert_eq!(
531 script.operations[0].op,
532 Op::Delete {
533 find: "console.log".to_string(),
534 regex: false,
535 case_insensitive: false,
536 }
537 );
538 }
539
540 #[test]
541 fn parse_delete_with_regex() {
542 let input = r#"delete -e "^\s*//\s*TODO:.*$""#;
543 let script = parse_script(input).unwrap();
544 let op = &script.operations[0].op;
545 assert!(op.is_regex());
546 assert_eq!(op.find_pattern(), r"^\s*//\s*TODO:.*$");
547 }
548
549 #[test]
552 fn parse_insert_after() {
553 let input = r#"insert_after "use serde;" "use serde_json;""#;
554 let script = parse_script(input).unwrap();
555 assert_eq!(
556 script.operations[0].op,
557 Op::InsertAfter {
558 find: "use serde;".to_string(),
559 content: "use serde_json;".to_string(),
560 regex: false,
561 case_insensitive: false,
562 }
563 );
564 }
565
566 #[test]
569 fn parse_insert_before() {
570 let input = r#"insert_before "fn main" "// Entry point""#;
571 let script = parse_script(input).unwrap();
572 assert_eq!(
573 script.operations[0].op,
574 Op::InsertBefore {
575 find: "fn main".to_string(),
576 content: "// Entry point".to_string(),
577 regex: false,
578 case_insensitive: false,
579 }
580 );
581 }
582
583 #[test]
586 fn parse_replace_line() {
587 let input = r#"replace_line "version = 1" "version = 2""#;
588 let script = parse_script(input).unwrap();
589 assert_eq!(
590 script.operations[0].op,
591 Op::ReplaceLine {
592 find: "version = 1".to_string(),
593 content: "version = 2".to_string(),
594 regex: false,
595 case_insensitive: false,
596 }
597 );
598 }
599
600 #[test]
603 fn parse_transform() {
604 let input = r#"transform "functionName" --mode snake_case"#;
605 let script = parse_script(input).unwrap();
606 assert_eq!(
607 script.operations[0].op,
608 Op::Transform {
609 find: "functionName".to_string(),
610 mode: TransformMode::SnakeCase,
611 regex: false,
612 case_insensitive: false,
613 }
614 );
615 }
616
617 #[test]
618 fn parse_transform_upper() {
619 let input = r#"transform "hello" --mode upper"#;
620 let script = parse_script(input).unwrap();
621 if let Op::Transform { mode, .. } = &script.operations[0].op {
622 assert_eq!(*mode, TransformMode::Upper);
623 } else {
624 panic!("expected Transform op");
625 }
626 }
627
628 #[test]
631 fn parse_surround() {
632 let input = r#"surround "word" --prefix "(" --suffix ")""#;
633 let script = parse_script(input).unwrap();
634 assert_eq!(
635 script.operations[0].op,
636 Op::Surround {
637 find: "word".to_string(),
638 prefix: "(".to_string(),
639 suffix: ")".to_string(),
640 regex: false,
641 case_insensitive: false,
642 }
643 );
644 }
645
646 #[test]
649 fn parse_indent_with_amount() {
650 let input = r#"indent "nested" --amount 4"#;
651 let script = parse_script(input).unwrap();
652 assert_eq!(
653 script.operations[0].op,
654 Op::Indent {
655 find: "nested".to_string(),
656 amount: 4,
657 use_tabs: false,
658 regex: false,
659 case_insensitive: false,
660 }
661 );
662 }
663
664 #[test]
665 fn parse_indent_default_amount() {
666 let input = r#"indent "nested""#;
667 let script = parse_script(input).unwrap();
668 if let Op::Indent { amount, .. } = &script.operations[0].op {
669 assert_eq!(*amount, 4);
670 } else {
671 panic!("expected Indent op");
672 }
673 }
674
675 #[test]
678 fn parse_dedent() {
679 let input = r#"dedent "over_indented" --amount 2"#;
680 let script = parse_script(input).unwrap();
681 assert_eq!(
682 script.operations[0].op,
683 Op::Dedent {
684 find: "over_indented".to_string(),
685 amount: 2,
686 use_tabs: false,
687 regex: false,
688 case_insensitive: false,
689 }
690 );
691 }
692
693 #[test]
696 fn parse_multi_operation_script() {
697 let input = r#"
698# Rename old_name to new_name
699replace "old_name" "new_name"
700
701# Remove debug lines
702delete -e "^\s*console\.log"
703
704# Add import
705insert_after "use serde;" "use serde_json;"
706"#;
707 let script = parse_script(input).unwrap();
708 assert_eq!(script.operations.len(), 3);
709
710 assert!(matches!(script.operations[0].op, Op::Replace { .. }));
711 assert!(matches!(script.operations[1].op, Op::Delete { .. }));
712 assert!(matches!(script.operations[2].op, Op::InsertAfter { .. }));
713 }
714
715 #[test]
718 fn error_unknown_operation() {
719 let input = r#"frobnicate "hello" "world""#;
720 let err = parse_script(input).unwrap_err();
721 assert!(err.message.contains("unknown operation"));
722 assert!(err.message.contains("line 1"));
723 }
724
725 #[test]
726 fn error_missing_args_for_replace() {
727 let input = r#"replace "only_one_arg""#;
728 let err = parse_script(input).unwrap_err();
729 assert!(err.message.contains("requires 2 argument"));
730 assert!(err.message.contains("line 1"));
731 }
732
733 #[test]
734 fn error_missing_args_for_delete() {
735 let input = "delete";
736 let err = parse_script(input).unwrap_err();
737 assert!(err.message.contains("requires 1 argument"));
738 }
739
740 #[test]
741 fn error_transform_missing_mode() {
742 let input = r#"transform "hello""#;
743 let err = parse_script(input).unwrap_err();
744 assert!(err.message.contains("--mode"));
745 }
746
747 #[test]
748 fn error_transform_invalid_mode() {
749 let input = r#"transform "hello" --mode invalid_mode"#;
750 let err = parse_script(input).unwrap_err();
751 assert!(err.message.contains("unknown transform mode"));
752 }
753
754 #[test]
755 fn error_surround_missing_prefix() {
756 let input = r#"surround "word" --suffix ")""#;
757 let err = parse_script(input).unwrap_err();
758 assert!(err.message.contains("--prefix"));
759 }
760
761 #[test]
762 fn error_surround_missing_suffix() {
763 let input = r#"surround "word" --prefix "("")"#;
764 let err = parse_script(input).unwrap_err();
765 assert!(err.message.contains("--suffix"));
766 }
767
768 #[test]
769 fn error_unknown_flag() {
770 let input = r#"replace --unknown "hello" "world""#;
771 let err = parse_script(input).unwrap_err();
772 assert!(err.message.contains("unknown flag"));
773 }
774
775 #[test]
776 fn error_glob_missing_value() {
777 let input = r#"replace "a" "b" --glob"#;
778 let err = parse_script(input).unwrap_err();
779 assert!(err.message.contains("--glob requires a value"));
780 }
781
782 #[test]
783 fn error_invalid_amount() {
784 let input = r#"indent "hello" --amount abc"#;
785 let err = parse_script(input).unwrap_err();
786 assert!(err.message.contains("invalid --amount"));
787 }
788
789 #[test]
790 fn error_line_number_is_correct() {
791 let input = "# comment\n\nreplace \"a\" \"b\"\nbad_op \"x\"";
792 let err = parse_script(input).unwrap_err();
793 assert!(
794 err.message.contains("line 4"),
795 "Error should reference line 4, got: {}",
796 err.message
797 );
798 }
799
800 #[test]
803 fn tokenize_simple() {
804 let tokens = tokenize("replace old new");
805 assert_eq!(tokens, vec!["replace", "old", "new"]);
806 }
807
808 #[test]
809 fn tokenize_double_quoted() {
810 let tokens = tokenize(r#"replace "hello world" "goodbye world""#);
811 assert_eq!(tokens, vec!["replace", "hello world", "goodbye world"]);
812 }
813
814 #[test]
815 fn tokenize_single_quoted() {
816 let tokens = tokenize("replace 'hello world' 'goodbye world'");
817 assert_eq!(tokens, vec!["replace", "hello world", "goodbye world"]);
818 }
819
820 #[test]
821 fn tokenize_mixed_quotes() {
822 let tokens = tokenize(r#"replace 'find this' "replace that""#);
823 assert_eq!(tokens, vec!["replace", "find this", "replace that"]);
824 }
825
826 #[test]
827 fn tokenize_escaped_double_quote() {
828 let tokens = tokenize(r#"replace "say \"hi\"" new"#);
829 assert_eq!(tokens, vec!["replace", r#"say "hi""#, "new"]);
830 }
831
832 #[test]
833 fn tokenize_flags() {
834 let tokens = tokenize(r#"replace -e "pattern" "replacement" --glob "*.rs""#);
835 assert_eq!(
836 tokens,
837 vec!["replace", "-e", "pattern", "replacement", "--glob", "*.rs"]
838 );
839 }
840
841 #[test]
842 fn tokenize_empty_string() {
843 let tokens = tokenize(r#"replace "" "new""#);
844 assert_eq!(tokens, vec!["replace", "", "new"]);
845 }
846
847 #[test]
850 fn parse_replace_regex_case_insensitive_glob() {
851 let input = r#"replace -e -i "pattern" "replacement" --glob "*.txt""#;
852 let script = parse_script(input).unwrap();
853 let sop = &script.operations[0];
854 assert!(sop.op.is_regex());
855 assert!(sop.op.is_case_insensitive());
856 assert_eq!(sop.glob, Some("*.txt".to_string()));
857 }
858
859 #[test]
860 fn parse_long_form_flags() {
861 let input = r#"replace --regex --case-insensitive "a" "b""#;
862 let script = parse_script(input).unwrap();
863 let op = &script.operations[0].op;
864 assert!(op.is_regex());
865 assert!(op.is_case_insensitive());
866 }
867
868 #[test]
869 fn parse_indent_with_use_tabs() {
870 let input = r#"indent "nested" --amount 1 --use-tabs"#;
871 let script = parse_script(input).unwrap();
872 if let Op::Indent {
873 amount, use_tabs, ..
874 } = &script.operations[0].op
875 {
876 assert_eq!(*amount, 1);
877 assert!(*use_tabs);
878 } else {
879 panic!("expected Indent op");
880 }
881 }
882}