Skip to main content

perl_quote/
lib.rs

1//! Uniform quote operator parsing for the Perl parser.
2//!
3//! This module provides consistent parsing for quote-like operators,
4//! properly extracting patterns, bodies, and modifiers.
5
6use std::borrow::Cow;
7
8/// Extract pattern and modifiers from a regex-like token (qr, m, or bare //)
9pub fn extract_regex_parts(text: &str) -> (String, String, String) {
10    // Handle different prefixes
11    let content = if let Some(stripped) = text.strip_prefix("qr") {
12        stripped
13    } else if text.starts_with('m')
14        && text.len() > 1
15        && text.chars().nth(1).is_some_and(|c| !c.is_alphabetic())
16    {
17        &text[1..]
18    } else {
19        text
20    };
21
22    // Get delimiter - content must be non-empty to have a delimiter
23    let delimiter = match content.chars().next() {
24        Some(d) => d,
25        None => return (String::new(), String::new(), String::new()),
26    };
27    let closing = get_closing_delimiter(delimiter);
28
29    // Extract body and modifiers
30    let (body, modifiers) = extract_delimited_content(content, delimiter, closing);
31
32    // Include delimiters in the pattern string for compatibility
33    let pattern = format!("{}{}{}", delimiter, body, closing);
34
35    (pattern, body, modifiers.to_string())
36}
37
38/// Error type for substitution operator parsing failures
39#[derive(Debug, Clone, PartialEq)]
40pub enum SubstitutionError {
41    /// Invalid modifier character found
42    InvalidModifier(char),
43    /// Missing delimiter after 's'
44    MissingDelimiter,
45    /// Pattern is missing or empty (just `s/`)
46    MissingPattern,
47    /// Replacement section is missing (e.g., `s/pattern` without replacement part)
48    MissingReplacement,
49    /// Closing delimiter is missing after replacement (e.g., `s/pattern/replacement` without final `/`)
50    MissingClosingDelimiter,
51}
52
53/// Extract pattern, replacement, and modifiers from a substitution token with strict validation
54///
55/// This function parses substitution operators like s/pattern/replacement/flags
56/// and handles various delimiter forms including:
57/// - Non-paired delimiters: s/pattern/replacement/ (same delimiter for all parts)
58/// - Paired delimiters: s{pattern}{replacement} (different open/close delimiters)
59///
60/// Unlike `extract_substitution_parts`, this function returns an error if invalid modifiers
61/// are present instead of silently filtering them.
62///
63/// # Errors
64///
65/// Returns `Err(SubstitutionError::InvalidModifier(c))` if an invalid modifier character is found.
66/// Valid modifiers are: g, i, m, s, x, o, e, r
67pub fn extract_substitution_parts_strict(
68    text: &str,
69) -> Result<(String, String, String), SubstitutionError> {
70    // Skip 's' prefix
71    let after_s = text.strip_prefix('s').unwrap_or(text);
72    // Perl allows whitespace between 's' and its delimiter (e.g. `s { pattern } { replacement }g`)
73    let content = after_s.trim_start();
74
75    // Get delimiter - check for missing delimiter (just 's' or 's' followed by nothing)
76    let delimiter = match content.chars().next() {
77        Some(d) => d,
78        None => return Err(SubstitutionError::MissingDelimiter),
79    };
80    let closing = get_closing_delimiter(delimiter);
81    let is_paired = delimiter != closing;
82
83    // Parse first body (pattern) with strict validation
84    let (pattern, rest1, pattern_closed) =
85        extract_delimited_content_strict(content, delimiter, closing);
86
87    // For non-paired delimiters: if pattern wasn't closed, missing closing delimiter
88    if !is_paired && !pattern_closed {
89        return Err(SubstitutionError::MissingClosingDelimiter);
90    }
91
92    // For paired delimiters: if pattern wasn't closed, missing closing delimiter
93    if is_paired && !pattern_closed {
94        return Err(SubstitutionError::MissingClosingDelimiter);
95    }
96
97    // Parse second body (replacement)
98    // For paired delimiters, the replacement may use a different delimiter than the pattern
99    // e.g., s[pattern]{replacement} is valid Perl
100    let (replacement, modifiers_str, replacement_closed) = if !is_paired {
101        // Non-paired delimiters: must have replacement section
102        if rest1.is_empty() {
103            return Err(SubstitutionError::MissingReplacement);
104        }
105
106        // Parse replacement, skipping string literals so that delimiter chars
107        // inside "foo/bar" or 'a/b' don't terminate the replacement early.
108        let (body, rest, found_closing) = extract_unpaired_body_skip_strings(rest1, closing);
109        (body, rest, found_closing)
110    } else {
111        // Paired delimiters
112        let trimmed = rest1.trim_start();
113        // For paired delimiters, check what delimiter the replacement uses
114        // It may be the same as pattern or a different paired delimiter
115        // e.g., s[pattern]{replacement} uses [] for pattern and {} for replacement
116        if let Some(rd) = trimmed.chars().next() {
117            // Check if it's a valid paired opening delimiter
118            if rd == '{' || rd == '[' || rd == '(' || rd == '<' {
119                let repl_closing = get_closing_delimiter(rd);
120                extract_delimited_content_strict(trimmed, rd, repl_closing)
121            } else {
122                // Not a valid paired delimiter - malformed
123                return Err(SubstitutionError::MissingReplacement);
124            }
125        } else {
126            // No more content - missing replacement
127            return Err(SubstitutionError::MissingReplacement);
128        }
129    };
130
131    // For non-paired delimiters, must have found the closing delimiter for replacement
132    if !is_paired && !replacement_closed {
133        return Err(SubstitutionError::MissingClosingDelimiter);
134    }
135
136    // For paired delimiters, must have found the closing delimiter for replacement
137    if is_paired && !replacement_closed {
138        return Err(SubstitutionError::MissingClosingDelimiter);
139    }
140
141    // Validate modifiers strictly - reject if any invalid modifiers present
142    let modifiers = validate_substitution_modifiers(modifiers_str)
143        .map_err(SubstitutionError::InvalidModifier)?;
144
145    Ok((pattern, replacement, modifiers))
146}
147
148/// Extract content between delimiters with strict tracking of whether closing was found.
149/// Returns (content, rest, found_closing).
150fn extract_delimited_content_strict(text: &str, open: char, close: char) -> (String, &str, bool) {
151    let mut chars = text.char_indices();
152    let is_paired = open != close;
153
154    // Skip opening delimiter
155    if let Some((_, c)) = chars.next() {
156        if c != open {
157            return (String::new(), text, false);
158        }
159    } else {
160        return (String::new(), "", false);
161    }
162
163    let mut body = String::new();
164    let mut depth = if is_paired { 1 } else { 0 };
165    let mut escaped = false;
166    let mut end_pos = text.len();
167    let mut found_closing = false;
168
169    for (i, ch) in chars {
170        if escaped {
171            body.push(ch);
172            escaped = false;
173            continue;
174        }
175
176        match ch {
177            '\\' => {
178                body.push(ch);
179                escaped = true;
180            }
181            c if c == open && is_paired => {
182                body.push(ch);
183                depth += 1;
184            }
185            c if c == close => {
186                if is_paired {
187                    depth -= 1;
188                    if depth == 0 {
189                        end_pos = i + ch.len_utf8();
190                        found_closing = true;
191                        break;
192                    }
193                    body.push(ch);
194                } else {
195                    end_pos = i + ch.len_utf8();
196                    found_closing = true;
197                    break;
198                }
199            }
200            _ => body.push(ch),
201        }
202    }
203
204    (body, &text[end_pos..], found_closing)
205}
206
207/// Extract pattern, replacement, and modifiers from a substitution token
208///
209/// This function parses substitution operators like s/pattern/replacement/flags
210/// and handles various delimiter forms including:
211/// - Non-paired delimiters: s/pattern/replacement/ (same delimiter for all parts)
212/// - Paired delimiters: s{pattern}{replacement} (different open/close delimiters)
213///
214/// For paired delimiters, properly handles nested delimiters within the pattern
215/// or replacement parts. Returns (pattern, replacement, modifiers) as strings.
216///
217/// Note: This function silently filters invalid modifiers. For strict validation,
218/// use `extract_substitution_parts_strict` instead.
219pub fn extract_substitution_parts(text: &str) -> (String, String, String) {
220    // Skip 's' prefix
221    let content = text.strip_prefix('s').unwrap_or(text);
222
223    // Get delimiter - content must be non-empty to have a delimiter
224    let delimiter = match content.chars().next() {
225        Some(d) => d,
226        None => return (String::new(), String::new(), String::new()),
227    };
228    if delimiter.is_ascii_alphanumeric() || delimiter.is_whitespace() {
229        if let Some((pattern, replacement, modifiers_str)) = split_on_last_paired_delimiter(content)
230        {
231            let modifiers = extract_substitution_modifiers(&modifiers_str);
232            return (pattern, replacement, modifiers);
233        }
234
235        return (String::new(), String::new(), String::new());
236    }
237    let closing = get_closing_delimiter(delimiter);
238    let is_paired = delimiter != closing;
239
240    // Parse first body (pattern)
241    let (mut pattern, rest1, pattern_closed) = if is_paired {
242        extract_substitution_pattern_with_replacement_hint(content, delimiter, closing)
243    } else {
244        extract_delimited_content_strict(content, delimiter, closing)
245    };
246
247    // Parse second body (replacement)
248    // For paired delimiters, the replacement may use a different delimiter than the pattern
249    // e.g., s[pattern]{replacement} is valid Perl
250    let (replacement, modifiers_str) = if !is_paired && !rest1.is_empty() {
251        // Non-paired delimiters: manually parse the replacement, skipping string literals
252        // so that delimiter chars inside "foo/bar" or 'a/b' don't end the replacement early.
253        let (body, rest, _found) = extract_unpaired_body_skip_strings(rest1, closing);
254        (body, Cow::Borrowed(rest))
255    } else if !is_paired && !pattern_closed {
256        if let Some((fallback_pattern, fallback_replacement, fallback_modifiers)) =
257            split_unclosed_substitution_pattern(&pattern)
258        {
259            pattern = fallback_pattern;
260            (fallback_replacement, Cow::Owned(fallback_modifiers))
261        } else {
262            (String::new(), Cow::Borrowed(rest1))
263        }
264    } else if is_paired {
265        let trimmed = rest1.trim_start();
266        // For paired delimiters, check what delimiter the replacement uses
267        // It may be the same as pattern or a different paired delimiter
268        // e.g., s[pattern]{replacement} uses [] for pattern and {} for replacement
269        if let Some(rd) = starts_with_paired_delimiter(trimmed) {
270            let repl_closing = get_closing_delimiter(rd);
271            let (body, rest) = extract_delimited_content(trimmed, rd, repl_closing);
272            (body, Cow::Borrowed(rest))
273        } else {
274            let (body, rest) = extract_unpaired_body(rest1, closing);
275            (body, Cow::Borrowed(rest))
276        }
277    } else {
278        (String::new(), Cow::Borrowed(rest1))
279    };
280
281    // Extract and validate only valid substitution modifiers
282    let modifiers = extract_substitution_modifiers(modifiers_str.as_ref());
283
284    (pattern, replacement, modifiers)
285}
286
287/// Extract search, replace, and modifiers from a transliteration token
288pub fn extract_transliteration_parts(text: &str) -> (String, String, String) {
289    // Skip 'tr' or 'y' prefix
290    let content = if let Some(stripped) = text.strip_prefix("tr") {
291        stripped
292    } else if let Some(stripped) = text.strip_prefix('y') {
293        stripped
294    } else {
295        text
296    };
297
298    // Get delimiter - content must be non-empty to have a delimiter
299    let delimiter = match content.chars().next() {
300        Some(d) => d,
301        None => return (String::new(), String::new(), String::new()),
302    };
303    let closing = get_closing_delimiter(delimiter);
304    let is_paired = delimiter != closing;
305
306    // Parse first body (search pattern)
307    let (search, rest1) = extract_delimited_content(content, delimiter, closing);
308
309    // For paired delimiters, skip whitespace and allow any paired opening delimiter for the
310    // replacement list. Perl accepts forms like tr[abc]{xyz} in addition to tr[abc][xyz].
311    let rest2_owned;
312    let rest2 = if is_paired {
313        rest1.trim_start()
314    } else {
315        rest2_owned = format!("{}{}", delimiter, rest1);
316        &rest2_owned
317    };
318
319    // Parse second body (replacement pattern)
320    let (replacement, modifiers_str) = if !is_paired && !rest1.is_empty() {
321        // Manually parse the replacement for non-paired delimiters
322        let chars = rest1.char_indices();
323        let mut body = String::new();
324        let mut escaped = false;
325        let mut end_pos = rest1.len();
326
327        for (i, ch) in chars {
328            if escaped {
329                body.push(ch);
330                escaped = false;
331                continue;
332            }
333
334            match ch {
335                '\\' => {
336                    body.push(ch);
337                    escaped = true;
338                }
339                c if c == closing => {
340                    end_pos = i + ch.len_utf8();
341                    break;
342                }
343                _ => body.push(ch),
344            }
345        }
346
347        (body, &rest1[end_pos..])
348    } else if is_paired {
349        if let Some(repl_delimiter) = starts_with_paired_delimiter(rest2) {
350            let repl_closing = get_closing_delimiter(repl_delimiter);
351            extract_delimited_content(rest2, repl_delimiter, repl_closing)
352        } else {
353            (String::new(), rest2)
354        }
355    } else {
356        (String::new(), rest1)
357    };
358
359    // Extract and validate only valid transliteration modifiers
360    // Security fix: Apply consistent validation for all delimiter types
361    let modifiers = modifiers_str
362        .chars()
363        .take_while(|c| c.is_ascii_alphabetic())
364        .filter(|&c| matches!(c, 'c' | 'd' | 's' | 'r'))
365        .collect();
366
367    (search, replacement, modifiers)
368}
369
370/// Get the closing delimiter for a given opening delimiter
371fn get_closing_delimiter(open: char) -> char {
372    match open {
373        '(' => ')',
374        '[' => ']',
375        '{' => '}',
376        '<' => '>',
377        _ => open,
378    }
379}
380
381fn is_paired_open(ch: char) -> bool {
382    matches!(ch, '{' | '[' | '(' | '<')
383}
384
385fn starts_with_paired_delimiter(text: &str) -> Option<char> {
386    let trimmed = text.trim_start();
387    match trimmed.chars().next() {
388        Some(ch) if is_paired_open(ch) => Some(ch),
389        _ => None,
390    }
391}
392
393/// Extract content between delimiters and return (content, rest)
394fn extract_delimited_content(text: &str, open: char, close: char) -> (String, &str) {
395    let mut chars = text.char_indices();
396    let is_paired = open != close;
397
398    // Skip opening delimiter
399    if let Some((_, c)) = chars.next() {
400        if c != open {
401            return (String::new(), text);
402        }
403    } else {
404        return (String::new(), "");
405    }
406
407    let mut body = String::new();
408    let mut depth = if is_paired { 1 } else { 0 };
409    let mut escaped = false;
410    let mut end_pos = text.len();
411
412    for (i, ch) in chars {
413        if escaped {
414            body.push(ch);
415            escaped = false;
416            continue;
417        }
418
419        match ch {
420            '\\' => {
421                body.push(ch);
422                escaped = true;
423            }
424            c if c == open && is_paired => {
425                body.push(ch);
426                depth += 1;
427            }
428            c if c == close => {
429                if is_paired {
430                    depth -= 1;
431                    if depth == 0 {
432                        end_pos = i + ch.len_utf8();
433                        break;
434                    }
435                    body.push(ch);
436                } else {
437                    end_pos = i + ch.len_utf8();
438                    break;
439                }
440            }
441            _ => body.push(ch),
442        }
443    }
444
445    (body, &text[end_pos..])
446}
447
448fn extract_unpaired_body(text: &str, closing: char) -> (String, &str) {
449    let mut body = String::new();
450    let mut escaped = false;
451    let mut end_pos = text.len();
452
453    for (i, ch) in text.char_indices() {
454        if escaped {
455            body.push(ch);
456            escaped = false;
457            continue;
458        }
459
460        match ch {
461            '\\' => {
462                body.push(ch);
463                escaped = true;
464            }
465            c if c == closing => {
466                end_pos = i + ch.len_utf8();
467                break;
468            }
469            _ => body.push(ch),
470        }
471    }
472
473    (body, &text[end_pos..])
474}
475
476/// Lookahead helper: determine whether a `quote` char at byte `pos` in `text` is the
477/// opening of a genuine inner string literal that protects `closing` delimiter chars.
478///
479/// Returns `Some((end_pos, true))` when:
480///   - A matching closing `quote` is found on the SAME LINE (no `\n` crossed), AND
481///   - The content between the two `quote` chars contains `closing`.
482///   - `end_pos` is the byte offset just after the closing `quote`.
483///
484/// Returns `None` (or `Some((_, false))`) when:
485///   - A newline or end of `text` is reached before the matching closing `quote`, OR
486///   - The string content does not contain `closing`.
487///
488/// Stopping at newlines prevents cross-statement false positives in multiline source.
489fn scan_inner_string(
490    text: &str,
491    pos: usize,
492    quote: char,
493    delimiter: char,
494) -> Option<(usize, bool)> {
495    let start = pos + quote.len_utf8();
496    let rest = text.get(start..)?;
497    let mut escaped = false;
498    let mut contains_delim = false;
499    let mut end_of_string = None;
500    let mut local_pos = start;
501    for ch in rest.chars() {
502        if escaped {
503            escaped = false;
504            local_pos += ch.len_utf8();
505            continue;
506        }
507        if ch == '\\' {
508            escaped = true;
509            local_pos += ch.len_utf8();
510            continue;
511        }
512        // Newline terminates the scan: inner string literals don't span lines.
513        if ch == '\n' {
514            return None;
515        }
516        if ch == delimiter {
517            contains_delim = true;
518        }
519        if ch == quote {
520            end_of_string = Some(local_pos + ch.len_utf8());
521            break;
522        }
523        local_pos += ch.len_utf8();
524    }
525    end_of_string.map(|end| (end, contains_delim))
526}
527
528/// Like `extract_unpaired_body` but skips over string literals (`"..."` / `'...'`)
529/// so that the closing delimiter character inside a string is not mistaken for the
530/// end of the replacement section.  Returns `(body, rest, found_closing)`.
531///
532/// Uses lookahead to determine whether a `'` or `"` is actually an inner string:
533/// only enters string-skip mode when the candidate string (a) has a matching closing
534/// quote on the same line AND (b) contains the closing delimiter in its content.
535/// This prevents lone apostrophes (e.g. the `'` in `s/''/'/g`) from triggering
536/// string-skip, which would cause replacement scanning to cross statement boundaries.
537fn extract_unpaired_body_skip_strings(text: &str, closing: char) -> (String, &str, bool) {
538    let mut body = String::new();
539    let mut end_pos = text.len();
540    let mut found_closing = false;
541    let mut pos = 0usize;
542    let mut escaped = false;
543
544    while let Some(ch) = text.get(pos..).and_then(|s| s.chars().next()) {
545        if escaped {
546            body.push(ch);
547            escaped = false;
548            pos += ch.len_utf8();
549            continue;
550        }
551
552        match ch {
553            '\\' => {
554                body.push(ch);
555                escaped = true;
556                pos += ch.len_utf8();
557            }
558            // Skip over string literals to avoid treating delimiter chars inside
559            // "foo/bar" or 'a/b' as the closing delimiter of the replacement.
560            //
561            // Guard: only enter string-skip when lookahead confirms a matching closing
562            // quote exists on the same line AND the content contains the closing delimiter.
563            '"' | '\'' if ch != closing => {
564                let quote = ch;
565                match scan_inner_string(text, pos, quote, closing) {
566                    Some((string_end, true)) => {
567                        // String content contains the closing delimiter → skip the string.
568                        let string_text = &text[pos..string_end];
569                        body.push_str(string_text);
570                        pos = string_end;
571                    }
572                    _ => {
573                        // No closing quote on same line, or content has no delimiter:
574                        // treat the opening quote as a literal character.
575                        body.push(ch);
576                        pos += ch.len_utf8();
577                    }
578                }
579            }
580            c if c == closing => {
581                end_pos = pos + ch.len_utf8();
582                found_closing = true;
583                break;
584            }
585            _ => {
586                body.push(ch);
587                pos += ch.len_utf8();
588            }
589        }
590    }
591
592    (body, &text[end_pos..], found_closing)
593}
594
595fn extract_substitution_pattern_with_replacement_hint(
596    text: &str,
597    open: char,
598    close: char,
599) -> (String, &str, bool) {
600    let mut chars = text.char_indices();
601
602    // Skip opening delimiter
603    if let Some((_, c)) = chars.next() {
604        if c != open {
605            return (String::new(), text, false);
606        }
607    } else {
608        return (String::new(), "", false);
609    }
610
611    let mut body = String::new();
612    let mut depth = 1usize;
613    let mut escaped = false;
614    let mut first_close_pos: Option<usize> = None;
615    let mut first_body_len: usize = 0;
616
617    for (i, ch) in chars {
618        if escaped {
619            body.push(ch);
620            escaped = false;
621            continue;
622        }
623
624        match ch {
625            '\\' => {
626                body.push(ch);
627                escaped = true;
628            }
629            c if c == open => {
630                body.push(ch);
631                depth += 1;
632            }
633            c if c == close => {
634                if depth > 1 {
635                    depth -= 1;
636                    body.push(ch);
637                    continue;
638                }
639
640                let rest = &text[i + ch.len_utf8()..];
641                if first_close_pos.is_none() {
642                    first_close_pos = Some(i + ch.len_utf8());
643                    first_body_len = body.len();
644                }
645
646                if starts_with_paired_delimiter(rest).is_some() {
647                    return (body, rest, true);
648                }
649
650                body.push(ch);
651            }
652            _ => body.push(ch),
653        }
654    }
655
656    if let Some(pos) = first_close_pos {
657        body.truncate(first_body_len);
658        return (body, &text[pos..], true);
659    }
660
661    (body, "", false)
662}
663
664fn split_unclosed_substitution_pattern(pattern: &str) -> Option<(String, String, String)> {
665    let mut escaped = false;
666
667    for (idx, ch) in pattern.char_indices() {
668        if escaped {
669            escaped = false;
670            continue;
671        }
672
673        if ch == '\\' {
674            escaped = true;
675            continue;
676        }
677
678        if is_paired_open(ch) {
679            let closing = get_closing_delimiter(ch);
680            let (replacement, rest, found_closing) =
681                extract_delimited_content_strict(&pattern[idx..], ch, closing);
682            if found_closing {
683                let leading = pattern[..idx].to_string();
684                return Some((leading, replacement, rest.to_string()));
685            }
686        }
687    }
688
689    None
690}
691
692fn split_on_last_paired_delimiter(text: &str) -> Option<(String, String, String)> {
693    let mut escaped = false;
694    let mut candidates = Vec::new();
695
696    for (idx, ch) in text.char_indices() {
697        if escaped {
698            escaped = false;
699            continue;
700        }
701
702        if ch == '\\' {
703            escaped = true;
704            continue;
705        }
706
707        if is_paired_open(ch) {
708            candidates.push((idx, ch));
709        }
710    }
711
712    for (idx, ch) in candidates.into_iter().rev() {
713        let closing = get_closing_delimiter(ch);
714        let (replacement, rest, found_closing) =
715            extract_delimited_content_strict(&text[idx..], ch, closing);
716        if found_closing {
717            let leading = text[..idx].to_string();
718            return Some((leading, replacement, rest.to_string()));
719        }
720    }
721
722    None
723}
724
725/// Extract and validate substitution modifiers, returning only valid ones
726///
727/// Valid Perl substitution modifiers include:
728/// - Core modifiers: g, i, m, s, x, o, e, r
729/// - Charset modifiers (Perl 5.14+): a, d, l, u
730/// - Additional modifiers: n (5.22+), p, c
731///
732/// This function provides panic-safe modifier validation for substitution operators,
733/// filtering out invalid modifiers to prevent security vulnerabilities.
734fn extract_substitution_modifiers(text: &str) -> String {
735    text.chars()
736        .take_while(|c| c.is_ascii_alphabetic())
737        .filter(|&c| {
738            matches!(
739                c,
740                'g' | 'i'
741                    | 'm'
742                    | 's'
743                    | 'x'
744                    | 'o'
745                    | 'e'
746                    | 'r'
747                    | 'a'
748                    | 'd'
749                    | 'l'
750                    | 'u'
751                    | 'n'
752                    | 'p'
753                    | 'c'
754            )
755        })
756        .collect()
757}
758
759/// Validate substitution modifiers and return an error if any are invalid
760///
761/// Valid Perl substitution modifiers include:
762/// - Core modifiers: g, i, m, s, x, o, e, r
763/// - Charset modifiers (Perl 5.14+): a, d, l, u
764/// - Additional modifiers: n (5.22+), p, c
765///
766/// # Arguments
767///
768/// * `modifiers_str` - The raw modifier string following the substitution operator
769///
770/// # Returns
771///
772/// * `Ok(String)` - The validated modifiers if all are valid
773/// * `Err(char)` - The first invalid modifier character encountered
774///
775/// # Examples
776///
777/// ```ignore
778/// assert!(validate_substitution_modifiers("gi").is_ok());
779/// assert!(validate_substitution_modifiers("gia").is_ok());  // 'a' for ASCII mode
780/// assert!(validate_substitution_modifiers("giz").is_err()); // 'z' is invalid
781/// ```
782pub fn validate_substitution_modifiers(modifiers_str: &str) -> Result<String, char> {
783    let mut valid_modifiers = String::new();
784
785    for c in modifiers_str.chars() {
786        // Stop at non-alphabetic characters (end of modifiers)
787        if !c.is_ascii_alphabetic() {
788            // If it's whitespace or end of input, that's ok
789            if c.is_whitespace() || c == ';' || c == '\n' || c == '\r' {
790                break;
791            }
792            // Non-alphabetic, non-whitespace character in modifier position is invalid
793            return Err(c);
794        }
795
796        // Check if it's a valid substitution modifier
797        if matches!(
798            c,
799            'g' | 'i' | 'm' | 's' | 'x' | 'o' | 'e' | 'r' | 'a' | 'd' | 'l' | 'u' | 'n' | 'p' | 'c'
800        ) {
801            valid_modifiers.push(c);
802        } else {
803            // Invalid alphabetic modifier
804            return Err(c);
805        }
806    }
807
808    Ok(valid_modifiers)
809}