Skip to main content

ripsed_core/
script.rs

1use crate::error::RipsedError;
2use crate::operation::{Op, TransformMode};
3
4/// A parsed .rip script file containing a sequence of operations.
5#[derive(Debug, Clone)]
6pub struct Script {
7    pub operations: Vec<ScriptOp>,
8}
9
10/// A single operation from a script file, optionally scoped to a glob pattern.
11#[derive(Debug, Clone)]
12pub struct ScriptOp {
13    pub op: Op,
14    pub glob: Option<String>,
15}
16
17/// Parse a .rip script from its text content.
18///
19/// Each non-empty, non-comment line is parsed as an operation.
20/// Comments start with `#` and blank lines are ignored.
21/// Returns a `RipsedError` with line number context on failure.
22pub 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        // Skip blank lines and comment lines
30        if trimmed.is_empty() || trimmed.starts_with('#') {
31            continue;
32        }
33
34        // Strip inline comments (# not inside quotes)
35        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
44/// Strip an inline comment from a line, respecting quoted strings.
45fn 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
71/// Tokenize a script line, respecting quoted strings.
72///
73/// Handles double-quoted strings with `\"` escapes, single-quoted strings
74/// (no escapes), and unquoted tokens separated by whitespace.
75fn 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    // Track whether we entered a quoted context for this token,
82    // so that empty quoted strings like "" produce an empty token.
83    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                            // Preserve the backslash and next char literally.
104                            // This keeps regex escapes like \s, \w, \d intact.
105                            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
147/// Parse a single script line into a `ScriptOp`.
148fn 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    // Extract shared flags from args
159    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    // --multiline only applies to replace and delete
246    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    // Count flags only apply to replace
254    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    // ── Comment and blank line handling ──
433
434    #[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    // ── Quoted string handling ──
479
480    #[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    // ── Replace operation ──
529
530    #[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    // ── Delete operation ──
655
656    #[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    // ── InsertAfter operation ──
681
682    #[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    // ── InsertBefore operation ──
698
699    #[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    // ── ReplaceLine operation ──
715
716    #[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    // ── Transform operation ──
732
733    #[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    // ── Surround operation ──
760
761    #[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    // ── Indent operation ──
778
779    #[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    // ── Dedent operation ──
807
808    #[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    // ── Multi-operation script ──
825
826    #[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    // ── Error cases ──
847
848    #[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    // ── Tokenizer tests ──
932
933    #[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    // ── Combination flags ──
979
980    #[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}