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 multiline = false;
162 let mut count = crate::operation::ReplaceCount::All;
163 let mut glob: Option<String> = None;
164 let mut positional: Vec<String> = Vec::new();
165 let mut named: std::collections::HashMap<String, String> = std::collections::HashMap::new();
166
167 let mut i = 0;
168 while i < args.len() {
169 match args[i].as_str() {
170 "-e" | "--regex" => regex = true,
171 "-i" | "--case-insensitive" => case_insensitive = true,
172 "-U" | "--multiline" => multiline = true,
173 "--first" => count = crate::operation::ReplaceCount::FirstPerLine,
174 "--first-in-file" => count = crate::operation::ReplaceCount::FirstInFile,
175 "--max-replacements" => {
176 i += 1;
177 if i >= args.len() {
178 return Err(script_error(
179 line_num,
180 "--max-replacements requires a value",
181 ));
182 }
183 let n: usize = args[i].parse().map_err(|_| {
184 script_error(
185 line_num,
186 &format!("--max-replacements: '{}' is not a valid number", args[i]),
187 )
188 })?;
189 if n == 0 {
190 return Err(script_error(
191 line_num,
192 "--max-replacements must be at least 1",
193 ));
194 }
195 count = crate::operation::ReplaceCount::Max(n);
196 }
197 "--glob" => {
198 i += 1;
199 if i >= args.len() {
200 return Err(script_error(line_num, "--glob requires a value"));
201 }
202 glob = Some(args[i].clone());
203 }
204 "--mode" => {
205 i += 1;
206 if i >= args.len() {
207 return Err(script_error(line_num, "--mode requires a value"));
208 }
209 named.insert("mode".to_string(), args[i].clone());
210 }
211 "--prefix" => {
212 i += 1;
213 if i >= args.len() {
214 return Err(script_error(line_num, "--prefix requires a value"));
215 }
216 named.insert("prefix".to_string(), args[i].clone());
217 }
218 "--suffix" => {
219 i += 1;
220 if i >= args.len() {
221 return Err(script_error(line_num, "--suffix requires a value"));
222 }
223 named.insert("suffix".to_string(), args[i].clone());
224 }
225 "--amount" => {
226 i += 1;
227 if i >= args.len() {
228 return Err(script_error(line_num, "--amount requires a value"));
229 }
230 named.insert("amount".to_string(), args[i].clone());
231 }
232 "--use-tabs" => {
233 named.insert("use_tabs".to_string(), "true".to_string());
234 }
235 other => {
236 if other.starts_with('-') {
237 return Err(script_error(line_num, &format!("unknown flag '{other}'")));
238 }
239 positional.push(other.to_string());
240 }
241 }
242 i += 1;
243 }
244
245 if multiline && !matches!(op_name.as_str(), "replace" | "delete") {
247 return Err(script_error(
248 line_num,
249 &format!("--multiline is not supported for '{op_name}' (replace and delete only)"),
250 ));
251 }
252
253 if count != crate::operation::ReplaceCount::All && op_name != "replace" {
255 return Err(script_error(
256 line_num,
257 &format!(
258 "--first/--first-in-file/--max-replacements are not supported for '{op_name}' (replace only)"
259 ),
260 ));
261 }
262 if multiline && count == crate::operation::ReplaceCount::FirstPerLine {
263 return Err(script_error(
264 line_num,
265 "--first cannot be combined with --multiline (use --first-in-file or --max-replacements)",
266 ));
267 }
268
269 let op = match op_name.as_str() {
270 "replace" => {
271 require_positional_count(&positional, 2, "replace", line_num)?;
272 Op::Replace {
273 count,
274 multiline,
275 find: positional[0].clone(),
276 replace: positional[1].clone(),
277 regex,
278 case_insensitive,
279 }
280 }
281 "delete" => {
282 require_positional_count(&positional, 1, "delete", line_num)?;
283 Op::Delete {
284 multiline,
285 find: positional[0].clone(),
286 regex,
287 case_insensitive,
288 }
289 }
290 "insert_after" => {
291 require_positional_count(&positional, 2, "insert_after", line_num)?;
292 Op::InsertAfter {
293 find: positional[0].clone(),
294 content: positional[1].clone(),
295 regex,
296 case_insensitive,
297 }
298 }
299 "insert_before" => {
300 require_positional_count(&positional, 2, "insert_before", line_num)?;
301 Op::InsertBefore {
302 find: positional[0].clone(),
303 content: positional[1].clone(),
304 regex,
305 case_insensitive,
306 }
307 }
308 "replace_line" => {
309 require_positional_count(&positional, 2, "replace_line", line_num)?;
310 Op::ReplaceLine {
311 find: positional[0].clone(),
312 content: positional[1].clone(),
313 regex,
314 case_insensitive,
315 }
316 }
317 "transform" => {
318 require_positional_count(&positional, 1, "transform", line_num)?;
319 let mode_str = named
320 .get("mode")
321 .ok_or_else(|| script_error(line_num, "transform requires --mode <mode>"))?;
322 let mode: TransformMode = mode_str
323 .parse()
324 .map_err(|e: String| script_error(line_num, &e))?;
325 Op::Transform {
326 find: positional[0].clone(),
327 mode,
328 regex,
329 case_insensitive,
330 }
331 }
332 "surround" => {
333 require_positional_count(&positional, 1, "surround", line_num)?;
334 let prefix = named
335 .get("prefix")
336 .ok_or_else(|| script_error(line_num, "surround requires --prefix <value>"))?
337 .clone();
338 let suffix = named
339 .get("suffix")
340 .ok_or_else(|| script_error(line_num, "surround requires --suffix <value>"))?
341 .clone();
342 Op::Surround {
343 find: positional[0].clone(),
344 prefix,
345 suffix,
346 regex,
347 case_insensitive,
348 }
349 }
350 "indent" => {
351 require_positional_count(&positional, 1, "indent", line_num)?;
352 let amount = parse_amount(&named, line_num, 4)?;
353 let use_tabs = named.contains_key("use_tabs");
354 Op::Indent {
355 find: positional[0].clone(),
356 amount,
357 use_tabs,
358 regex,
359 case_insensitive,
360 }
361 }
362 "dedent" => {
363 require_positional_count(&positional, 1, "dedent", line_num)?;
364 let amount = parse_amount(&named, line_num, 4)?;
365 let use_tabs = named.contains_key("use_tabs");
366 Op::Dedent {
367 find: positional[0].clone(),
368 amount,
369 use_tabs,
370 regex,
371 case_insensitive,
372 }
373 }
374 other => {
375 return Err(script_error(
376 line_num,
377 &format!(
378 "unknown operation '{other}'. Valid operations: replace, delete, \
379 insert_after, insert_before, replace_line, transform, surround, \
380 indent, dedent"
381 ),
382 ));
383 }
384 };
385
386 Ok(ScriptOp { op, glob })
387}
388
389fn require_positional_count(
390 positional: &[String],
391 expected: usize,
392 op_name: &str,
393 line_num: usize,
394) -> Result<(), RipsedError> {
395 if positional.len() < expected {
396 return Err(script_error(
397 line_num,
398 &format!(
399 "'{op_name}' requires {expected} argument(s), got {}",
400 positional.len()
401 ),
402 ));
403 }
404 Ok(())
405}
406
407fn parse_amount(
408 named: &std::collections::HashMap<String, String>,
409 line_num: usize,
410 default: usize,
411) -> Result<usize, RipsedError> {
412 match named.get("amount") {
413 Some(s) => s
414 .parse::<usize>()
415 .map_err(|_| script_error(line_num, &format!("invalid --amount value: '{s}'"))),
416 None => Ok(default),
417 }
418}
419
420fn script_error(line_num: usize, detail: &str) -> RipsedError {
421 RipsedError::invalid_request(
422 format!("Script parse error at line {line_num}: {detail}"),
423 format!("Check the syntax at line {line_num} of your .rip script file."),
424 )
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430 use crate::operation::TransformMode;
431
432 #[test]
435 fn empty_script_produces_no_ops() {
436 let script = parse_script("").unwrap();
437 assert!(script.operations.is_empty());
438 }
439
440 #[test]
441 fn comments_and_blank_lines_are_skipped() {
442 let input = r#"
443# This is a comment
444 # Indented comment
445
446# Another comment
447"#;
448 let script = parse_script(input).unwrap();
449 assert!(script.operations.is_empty());
450 }
451
452 #[test]
453 fn inline_comments_are_stripped() {
454 let input = r#"replace "old" "new" # this is a comment"#;
455 let script = parse_script(input).unwrap();
456 assert_eq!(script.operations.len(), 1);
457 if let Op::Replace { find, replace, .. } = &script.operations[0].op {
458 assert_eq!(find, "old");
459 assert_eq!(replace, "new");
460 } else {
461 panic!("expected Replace op");
462 }
463 }
464
465 #[test]
466 fn hash_inside_quotes_is_not_a_comment() {
467 let input = r#"replace "old#value" "new#value""#;
468 let script = parse_script(input).unwrap();
469 assert_eq!(script.operations.len(), 1);
470 if let Op::Replace { find, replace, .. } = &script.operations[0].op {
471 assert_eq!(find, "old#value");
472 assert_eq!(replace, "new#value");
473 } else {
474 panic!("expected Replace op");
475 }
476 }
477
478 #[test]
481 fn double_quoted_strings_with_spaces() {
482 let input = r#"replace "hello world" "goodbye world""#;
483 let script = parse_script(input).unwrap();
484 if let Op::Replace { find, replace, .. } = &script.operations[0].op {
485 assert_eq!(find, "hello world");
486 assert_eq!(replace, "goodbye world");
487 } else {
488 panic!("expected Replace op");
489 }
490 }
491
492 #[test]
493 fn single_quoted_strings() {
494 let input = "replace 'hello world' 'goodbye world'";
495 let script = parse_script(input).unwrap();
496 if let Op::Replace { find, replace, .. } = &script.operations[0].op {
497 assert_eq!(find, "hello world");
498 assert_eq!(replace, "goodbye world");
499 } else {
500 panic!("expected Replace op");
501 }
502 }
503
504 #[test]
505 fn escaped_quotes_in_double_quotes() {
506 let input = r#"replace "say \"hello\"" "say \"goodbye\"""#;
507 let script = parse_script(input).unwrap();
508 if let Op::Replace { find, replace, .. } = &script.operations[0].op {
509 assert_eq!(find, r#"say "hello""#);
510 assert_eq!(replace, r#"say "goodbye""#);
511 } else {
512 panic!("expected Replace op");
513 }
514 }
515
516 #[test]
517 fn unquoted_strings() {
518 let input = "replace old_name new_name";
519 let script = parse_script(input).unwrap();
520 if let Op::Replace { find, replace, .. } = &script.operations[0].op {
521 assert_eq!(find, "old_name");
522 assert_eq!(replace, "new_name");
523 } else {
524 panic!("expected Replace op");
525 }
526 }
527
528 #[test]
531 fn parse_replace_basic() {
532 let input = r#"replace "old" "new""#;
533 let script = parse_script(input).unwrap();
534 assert_eq!(script.operations.len(), 1);
535 let op = &script.operations[0].op;
536 assert_eq!(
537 *op,
538 Op::Replace {
539 count: Default::default(),
540 multiline: false,
541 find: "old".to_string(),
542 replace: "new".to_string(),
543 regex: false,
544 case_insensitive: false,
545 }
546 );
547 }
548
549 #[test]
550 fn parse_replace_with_multiline_flag() {
551 for flag in ["--multiline", "-U"] {
552 let input = format!(r#"replace {flag} "old" "new""#);
553 let script = parse_script(&input).unwrap();
554 assert!(
555 script.operations[0].op.is_multiline(),
556 "{flag} should set multiline"
557 );
558 }
559 }
560
561 #[test]
562 fn parse_delete_with_multiline_flag() {
563 let input = r#"delete -U "start.*end" -e"#;
564 let script = parse_script(input).unwrap();
565 let op = &script.operations[0].op;
566 assert!(op.is_multiline());
567 assert!(op.is_regex());
568 }
569
570 #[test]
571 fn parse_count_flags_on_replace() {
572 use crate::operation::ReplaceCount;
573 for (flag, expected) in [
574 ("--first", ReplaceCount::FirstPerLine),
575 ("--first-in-file", ReplaceCount::FirstInFile),
576 ("--max-replacements 3", ReplaceCount::Max(3)),
577 ] {
578 let input = format!(r#"replace {flag} "old" "new""#);
579 let script = parse_script(&input).unwrap();
580 match &script.operations[0].op {
581 Op::Replace { count, .. } => assert_eq!(*count, expected, "flag {flag}"),
582 other => panic!("expected Replace, got {other:?}"),
583 }
584 }
585 }
586
587 #[test]
588 fn parse_count_flags_rejected_for_non_replace() {
589 let err = parse_script(r#"delete "x" --first"#).unwrap_err();
590 assert!(err.message.contains("replace only"));
591 }
592
593 #[test]
594 fn parse_max_replacements_zero_rejected() {
595 let err = parse_script(r#"replace "a" "b" --max-replacements 0"#).unwrap_err();
596 assert!(err.message.contains("at least 1"));
597 }
598
599 #[test]
600 fn parse_first_with_multiline_rejected() {
601 let err = parse_script(r#"replace "a" "b" --first -U"#).unwrap_err();
602 assert!(err.message.contains("cannot be combined"));
603 }
604
605 #[test]
606 fn parse_multiline_rejected_for_line_scoped_ops() {
607 for line in [
608 r#"transform "x" --mode upper --multiline"#,
609 r#"insert_after "x" "y" -U"#,
610 r#"indent "x" --amount 2 --multiline"#,
611 ] {
612 let err = parse_script(line).unwrap_err();
613 assert!(
614 err.message.contains("--multiline is not supported"),
615 "expected multiline rejection for {line:?}, got: {}",
616 err.message
617 );
618 }
619 }
620
621 #[test]
622 fn parse_replace_with_regex() {
623 let input = r#"replace -e "fn\s+old_(\w+)" "fn new_$1""#;
624 let script = parse_script(input).unwrap();
625 let op = &script.operations[0].op;
626 assert_eq!(
627 *op,
628 Op::Replace {
629 count: Default::default(),
630 multiline: false,
631 find: r"fn\s+old_(\w+)".to_string(),
632 replace: "fn new_$1".to_string(),
633 regex: true,
634 case_insensitive: false,
635 }
636 );
637 }
638
639 #[test]
640 fn parse_replace_with_case_insensitive() {
641 let input = r#"replace -i "hello" "goodbye""#;
642 let script = parse_script(input).unwrap();
643 let op = &script.operations[0].op;
644 assert!(op.is_case_insensitive());
645 }
646
647 #[test]
648 fn parse_replace_with_glob() {
649 let input = r#"replace "old" "new" --glob "*.rs""#;
650 let script = parse_script(input).unwrap();
651 assert_eq!(script.operations[0].glob, Some("*.rs".to_string()));
652 }
653
654 #[test]
657 fn parse_delete_basic() {
658 let input = r#"delete "console.log""#;
659 let script = parse_script(input).unwrap();
660 assert_eq!(
661 script.operations[0].op,
662 Op::Delete {
663 multiline: false,
664 find: "console.log".to_string(),
665 regex: false,
666 case_insensitive: false,
667 }
668 );
669 }
670
671 #[test]
672 fn parse_delete_with_regex() {
673 let input = r#"delete -e "^\s*//\s*TODO:.*$""#;
674 let script = parse_script(input).unwrap();
675 let op = &script.operations[0].op;
676 assert!(op.is_regex());
677 assert_eq!(op.find_pattern(), r"^\s*//\s*TODO:.*$");
678 }
679
680 #[test]
683 fn parse_insert_after() {
684 let input = r#"insert_after "use serde;" "use serde_json;""#;
685 let script = parse_script(input).unwrap();
686 assert_eq!(
687 script.operations[0].op,
688 Op::InsertAfter {
689 find: "use serde;".to_string(),
690 content: "use serde_json;".to_string(),
691 regex: false,
692 case_insensitive: false,
693 }
694 );
695 }
696
697 #[test]
700 fn parse_insert_before() {
701 let input = r#"insert_before "fn main" "// Entry point""#;
702 let script = parse_script(input).unwrap();
703 assert_eq!(
704 script.operations[0].op,
705 Op::InsertBefore {
706 find: "fn main".to_string(),
707 content: "// Entry point".to_string(),
708 regex: false,
709 case_insensitive: false,
710 }
711 );
712 }
713
714 #[test]
717 fn parse_replace_line() {
718 let input = r#"replace_line "version = 1" "version = 2""#;
719 let script = parse_script(input).unwrap();
720 assert_eq!(
721 script.operations[0].op,
722 Op::ReplaceLine {
723 find: "version = 1".to_string(),
724 content: "version = 2".to_string(),
725 regex: false,
726 case_insensitive: false,
727 }
728 );
729 }
730
731 #[test]
734 fn parse_transform() {
735 let input = r#"transform "functionName" --mode snake_case"#;
736 let script = parse_script(input).unwrap();
737 assert_eq!(
738 script.operations[0].op,
739 Op::Transform {
740 find: "functionName".to_string(),
741 mode: TransformMode::SnakeCase,
742 regex: false,
743 case_insensitive: false,
744 }
745 );
746 }
747
748 #[test]
749 fn parse_transform_upper() {
750 let input = r#"transform "hello" --mode upper"#;
751 let script = parse_script(input).unwrap();
752 if let Op::Transform { mode, .. } = &script.operations[0].op {
753 assert_eq!(*mode, TransformMode::Upper);
754 } else {
755 panic!("expected Transform op");
756 }
757 }
758
759 #[test]
762 fn parse_surround() {
763 let input = r#"surround "word" --prefix "(" --suffix ")""#;
764 let script = parse_script(input).unwrap();
765 assert_eq!(
766 script.operations[0].op,
767 Op::Surround {
768 find: "word".to_string(),
769 prefix: "(".to_string(),
770 suffix: ")".to_string(),
771 regex: false,
772 case_insensitive: false,
773 }
774 );
775 }
776
777 #[test]
780 fn parse_indent_with_amount() {
781 let input = r#"indent "nested" --amount 4"#;
782 let script = parse_script(input).unwrap();
783 assert_eq!(
784 script.operations[0].op,
785 Op::Indent {
786 find: "nested".to_string(),
787 amount: 4,
788 use_tabs: false,
789 regex: false,
790 case_insensitive: false,
791 }
792 );
793 }
794
795 #[test]
796 fn parse_indent_default_amount() {
797 let input = r#"indent "nested""#;
798 let script = parse_script(input).unwrap();
799 if let Op::Indent { amount, .. } = &script.operations[0].op {
800 assert_eq!(*amount, 4);
801 } else {
802 panic!("expected Indent op");
803 }
804 }
805
806 #[test]
809 fn parse_dedent() {
810 let input = r#"dedent "over_indented" --amount 2"#;
811 let script = parse_script(input).unwrap();
812 assert_eq!(
813 script.operations[0].op,
814 Op::Dedent {
815 find: "over_indented".to_string(),
816 amount: 2,
817 use_tabs: false,
818 regex: false,
819 case_insensitive: false,
820 }
821 );
822 }
823
824 #[test]
827 fn parse_multi_operation_script() {
828 let input = r#"
829# Rename old_name to new_name
830replace "old_name" "new_name"
831
832# Remove debug lines
833delete -e "^\s*console\.log"
834
835# Add import
836insert_after "use serde;" "use serde_json;"
837"#;
838 let script = parse_script(input).unwrap();
839 assert_eq!(script.operations.len(), 3);
840
841 assert!(matches!(script.operations[0].op, Op::Replace { .. }));
842 assert!(matches!(script.operations[1].op, Op::Delete { .. }));
843 assert!(matches!(script.operations[2].op, Op::InsertAfter { .. }));
844 }
845
846 #[test]
849 fn error_unknown_operation() {
850 let input = r#"frobnicate "hello" "world""#;
851 let err = parse_script(input).unwrap_err();
852 assert!(err.message.contains("unknown operation"));
853 assert!(err.message.contains("line 1"));
854 }
855
856 #[test]
857 fn error_missing_args_for_replace() {
858 let input = r#"replace "only_one_arg""#;
859 let err = parse_script(input).unwrap_err();
860 assert!(err.message.contains("requires 2 argument"));
861 assert!(err.message.contains("line 1"));
862 }
863
864 #[test]
865 fn error_missing_args_for_delete() {
866 let input = "delete";
867 let err = parse_script(input).unwrap_err();
868 assert!(err.message.contains("requires 1 argument"));
869 }
870
871 #[test]
872 fn error_transform_missing_mode() {
873 let input = r#"transform "hello""#;
874 let err = parse_script(input).unwrap_err();
875 assert!(err.message.contains("--mode"));
876 }
877
878 #[test]
879 fn error_transform_invalid_mode() {
880 let input = r#"transform "hello" --mode invalid_mode"#;
881 let err = parse_script(input).unwrap_err();
882 assert!(err.message.contains("unknown transform mode"));
883 }
884
885 #[test]
886 fn error_surround_missing_prefix() {
887 let input = r#"surround "word" --suffix ")""#;
888 let err = parse_script(input).unwrap_err();
889 assert!(err.message.contains("--prefix"));
890 }
891
892 #[test]
893 fn error_surround_missing_suffix() {
894 let input = r#"surround "word" --prefix "("")"#;
895 let err = parse_script(input).unwrap_err();
896 assert!(err.message.contains("--suffix"));
897 }
898
899 #[test]
900 fn error_unknown_flag() {
901 let input = r#"replace --unknown "hello" "world""#;
902 let err = parse_script(input).unwrap_err();
903 assert!(err.message.contains("unknown flag"));
904 }
905
906 #[test]
907 fn error_glob_missing_value() {
908 let input = r#"replace "a" "b" --glob"#;
909 let err = parse_script(input).unwrap_err();
910 assert!(err.message.contains("--glob requires a value"));
911 }
912
913 #[test]
914 fn error_invalid_amount() {
915 let input = r#"indent "hello" --amount abc"#;
916 let err = parse_script(input).unwrap_err();
917 assert!(err.message.contains("invalid --amount"));
918 }
919
920 #[test]
921 fn error_line_number_is_correct() {
922 let input = "# comment\n\nreplace \"a\" \"b\"\nbad_op \"x\"";
923 let err = parse_script(input).unwrap_err();
924 assert!(
925 err.message.contains("line 4"),
926 "Error should reference line 4, got: {}",
927 err.message
928 );
929 }
930
931 #[test]
934 fn tokenize_simple() {
935 let tokens = tokenize("replace old new");
936 assert_eq!(tokens, vec!["replace", "old", "new"]);
937 }
938
939 #[test]
940 fn tokenize_double_quoted() {
941 let tokens = tokenize(r#"replace "hello world" "goodbye world""#);
942 assert_eq!(tokens, vec!["replace", "hello world", "goodbye world"]);
943 }
944
945 #[test]
946 fn tokenize_single_quoted() {
947 let tokens = tokenize("replace 'hello world' 'goodbye world'");
948 assert_eq!(tokens, vec!["replace", "hello world", "goodbye world"]);
949 }
950
951 #[test]
952 fn tokenize_mixed_quotes() {
953 let tokens = tokenize(r#"replace 'find this' "replace that""#);
954 assert_eq!(tokens, vec!["replace", "find this", "replace that"]);
955 }
956
957 #[test]
958 fn tokenize_escaped_double_quote() {
959 let tokens = tokenize(r#"replace "say \"hi\"" new"#);
960 assert_eq!(tokens, vec!["replace", r#"say "hi""#, "new"]);
961 }
962
963 #[test]
964 fn tokenize_flags() {
965 let tokens = tokenize(r#"replace -e "pattern" "replacement" --glob "*.rs""#);
966 assert_eq!(
967 tokens,
968 vec!["replace", "-e", "pattern", "replacement", "--glob", "*.rs"]
969 );
970 }
971
972 #[test]
973 fn tokenize_empty_string() {
974 let tokens = tokenize(r#"replace "" "new""#);
975 assert_eq!(tokens, vec!["replace", "", "new"]);
976 }
977
978 #[test]
981 fn parse_replace_regex_case_insensitive_glob() {
982 let input = r#"replace -e -i "pattern" "replacement" --glob "*.txt""#;
983 let script = parse_script(input).unwrap();
984 let sop = &script.operations[0];
985 assert!(sop.op.is_regex());
986 assert!(sop.op.is_case_insensitive());
987 assert_eq!(sop.glob, Some("*.txt".to_string()));
988 }
989
990 #[test]
991 fn parse_long_form_flags() {
992 let input = r#"replace --regex --case-insensitive "a" "b""#;
993 let script = parse_script(input).unwrap();
994 let op = &script.operations[0].op;
995 assert!(op.is_regex());
996 assert!(op.is_case_insensitive());
997 }
998
999 #[test]
1000 fn parse_indent_with_use_tabs() {
1001 let input = r#"indent "nested" --amount 1 --use-tabs"#;
1002 let script = parse_script(input).unwrap();
1003 if let Op::Indent {
1004 amount, use_tabs, ..
1005 } = &script.operations[0].op
1006 {
1007 assert_eq!(*amount, 1);
1008 assert!(*use_tabs);
1009 } else {
1010 panic!("expected Indent op");
1011 }
1012 }
1013}