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            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    // ── Comment and blank line handling ──
379
380    #[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    // ── Quoted string handling ──
425
426    #[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    // ── Replace operation ──
475
476    #[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    // ── Delete operation ──
525
526    #[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    // ── InsertAfter operation ──
550
551    #[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    // ── InsertBefore operation ──
567
568    #[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    // ── ReplaceLine operation ──
584
585    #[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    // ── Transform operation ──
601
602    #[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    // ── Surround operation ──
629
630    #[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    // ── Indent operation ──
647
648    #[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    // ── Dedent operation ──
676
677    #[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    // ── Multi-operation script ──
694
695    #[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    // ── Error cases ──
716
717    #[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    // ── Tokenizer tests ──
801
802    #[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    // ── Combination flags ──
848
849    #[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}