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 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    // ── Comment and blank line handling ──
377
378    #[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    // ── Quoted string handling ──
423
424    #[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    // ── Replace operation ──
473
474    #[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    // ── Delete operation ──
523
524    #[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    // ── InsertAfter operation ──
548
549    #[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    // ── InsertBefore operation ──
565
566    #[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    // ── ReplaceLine operation ──
582
583    #[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    // ── Transform operation ──
599
600    #[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    // ── Surround operation ──
627
628    #[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    // ── Indent operation ──
645
646    #[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    // ── Dedent operation ──
674
675    #[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    // ── Multi-operation script ──
691
692    #[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    // ── Error cases ──
713
714    #[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    // ── Tokenizer tests ──
798
799    #[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    // ── Combination flags ──
845
846    #[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}